diff --git a/.github/scripts/install.sh b/.github/scripts/install.sh index 3176229dfc1..30639e1b851 100755 --- a/.github/scripts/install.sh +++ b/.github/scripts/install.sh @@ -30,6 +30,9 @@ if [ "$USE_CONDA" = "true" ]; then # So, pinning it to 8.14 for now. micromamba install ipython=8.14 + # Install Pylint 3 to run our tests with it + micromamba install pylint=3 -q -y + else # Update pip and setuptools python -m pip install -U pip setuptools wheel build @@ -46,6 +49,9 @@ else # To check our manifest pip install -q check-manifest + # Install Pylint 3 to run our tests with it + pip install -U pylint + # This allows the test suite to run more reliably on Linux if [ "$OS" = "linux" ]; then pip uninstall pyqt5 pyqt5-qt5 pyqt5-sip pyqtwebengine pyqtwebengine-qt5 -q -y diff --git a/binder/environment.yml b/binder/environment.yml index bac73fdddf5..f7944e627df 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -28,7 +28,7 @@ dependencies: - pickleshare >=0.4 - psutil >=5.3 - pygments >=2.0 -- pylint >=2.5.0,<3.0 +- pylint >=2.5.0,<3.1 - pylint-venv >=3.0.2 - pyls-spyder >=0.4.0 - pyqt <5.16 diff --git a/external-deps/python-lsp-server/.gitrepo b/external-deps/python-lsp-server/.gitrepo index 2a99fdf1601..5b601fff00b 100644 --- a/external-deps/python-lsp-server/.gitrepo +++ b/external-deps/python-lsp-server/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = https://github.com/python-lsp/python-lsp-server.git branch = develop - commit = 6059aa39e4fcc7b645c2f2c7f3d9463c1ab58f4c - parent = aef7516b11aee330ad25e86885650549822e4893 + commit = 5f53f8e7ecacaa2e2ea91bc8707729980dffc407 + parent = c2ba41d868c05ac8c0337037966c936f3d47fcf5 method = merge cmdver = 0.4.3 diff --git a/external-deps/python-lsp-server/CHANGELOG.md b/external-deps/python-lsp-server/CHANGELOG.md index ed80043636a..eb9c7957afe 100644 --- a/external-deps/python-lsp-server/CHANGELOG.md +++ b/external-deps/python-lsp-server/CHANGELOG.md @@ -1,5 +1,45 @@ # History of changes +## Version 1.8.2 (2023/10/09) + +### Issues Closed + +* [Issue 453](https://github.com/python-lsp/python-lsp-server/issues/453) - notebookDocumentSync notebookSelector type error ([PR 454](https://github.com/python-lsp/python-lsp-server/pull/454) by [@smacke](https://github.com/smacke)) + +In this release 1 issue was closed. + +### Pull Requests Merged + +* [PR 454](https://github.com/python-lsp/python-lsp-server/pull/454) - Fix notebook document selector not being a list in capabilities, by [@smacke](https://github.com/smacke) ([453](https://github.com/python-lsp/python-lsp-server/issues/453)) + +In this release 1 pull request was closed. + +---- + +## Version 1.8.1 (2023/10/05) + +### Issues Closed + +* [Issue 439](https://github.com/python-lsp/python-lsp-server/issues/439) - `includeDeclaration` is no longer respected in `textDocument/references` ([PR 440](https://github.com/python-lsp/python-lsp-server/pull/440) by [@krassowski](https://github.com/krassowski)) +* [Issue 438](https://github.com/python-lsp/python-lsp-server/issues/438) - flake8 can error out when deleting lines ([PR 441](https://github.com/python-lsp/python-lsp-server/pull/441) by [@krassowski](https://github.com/krassowski)) +* [Issue 413](https://github.com/python-lsp/python-lsp-server/issues/413) - textDocument/rename reports positions outside of the document ([PR 450](https://github.com/python-lsp/python-lsp-server/pull/450) by [@ccordoba12](https://github.com/ccordoba12)) + +In this release 3 issues were closed. + +### Pull Requests Merged + +* [PR 450](https://github.com/python-lsp/python-lsp-server/pull/450) - Fix renaming when file has no EOLs, by [@ccordoba12](https://github.com/ccordoba12) ([413](https://github.com/python-lsp/python-lsp-server/issues/413)) +* [PR 449](https://github.com/python-lsp/python-lsp-server/pull/449) - Increase minimal required version of autopep8 to `>=2.0.4,<2.1.0`, by [@ccordoba12](https://github.com/ccordoba12) +* [PR 447](https://github.com/python-lsp/python-lsp-server/pull/447) - Fix numpy go-to-definition by taking it off autoimport list for this case, by [@smacke](https://github.com/smacke) +* [PR 443](https://github.com/python-lsp/python-lsp-server/pull/443) - Allow Jedi "goto" to perform multiple hops for "go to definition", by [@smacke](https://github.com/smacke) +* [PR 441](https://github.com/python-lsp/python-lsp-server/pull/441) - Pass a single copy of the document's source around for flake8, by [@krassowski](https://github.com/krassowski) ([438](https://github.com/python-lsp/python-lsp-server/issues/438)) +* [PR 440](https://github.com/python-lsp/python-lsp-server/pull/440) - Fix `include_declaration` handling in references request, by [@krassowski](https://github.com/krassowski) ([439](https://github.com/python-lsp/python-lsp-server/issues/439)) +* [PR 436](https://github.com/python-lsp/python-lsp-server/pull/436) - Add black reformatting commit to `.git-blame-ignore-revs`, by [@krassowski](https://github.com/krassowski) + +In this release 7 pull requests were closed. + +---- + ## Version 1.8.0 (2023/09/08) ### New features diff --git a/external-deps/python-lsp-server/CONFIGURATION.md b/external-deps/python-lsp-server/CONFIGURATION.md index f2626e4463c..acf8a85fb40 100644 --- a/external-deps/python-lsp-server/CONFIGURATION.md +++ b/external-deps/python-lsp-server/CONFIGURATION.md @@ -8,6 +8,7 @@ This server can be configured using the `workspace/didChangeConfiguration` metho | `pylsp.plugins.flake8.config` | `string` | Path to the config file that will be the authoritative config source. | `null` | | `pylsp.plugins.flake8.enabled` | `boolean` | Enable or disable the plugin. | `false` | | `pylsp.plugins.flake8.exclude` | `array` of `string` items | List of files or directories to exclude. | `[]` | +| `pylsp.plugins.flake8.extendIgnore` | `array` of `string` items | List of errors and warnings to append to ignore list. | `[]` | | `pylsp.plugins.flake8.executable` | `string` | Path to the flake8 executable. | `"flake8"` | | `pylsp.plugins.flake8.filename` | `string` | Only check for filenames matching the patterns in this list. | `null` | | `pylsp.plugins.flake8.hangClosing` | `boolean` | Hang closing bracket instead of matching indentation of opening bracket's line. | `null` | @@ -63,7 +64,9 @@ This server can be configured using the `workspace/didChangeConfiguration` metho | `pylsp.plugins.pylint.enabled` | `boolean` | Enable or disable the plugin. | `false` | | `pylsp.plugins.pylint.args` | `array` of non-unique `string` items | Arguments to pass to pylint. | `[]` | | `pylsp.plugins.pylint.executable` | `string` | Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3. | `null` | -| `pylsp.plugins.rope_autoimport.enabled` | `boolean` | Enable or disable autoimport. | `false` | +| `pylsp.plugins.rope_autoimport.enabled` | `boolean` | Enable or disable autoimport. If false, neither completions nor code actions are enabled. If true, the respective features can be enabled or disabled individually. | `false` | +| `pylsp.plugins.rope_autoimport.completions.enabled` | `boolean` | Enable or disable autoimport completions. | `true` | +| `pylsp.plugins.rope_autoimport.code_actions.enabled` | `boolean` | Enable or disable autoimport code actions (e.g. for quick fixes). | `true` | | `pylsp.plugins.rope_autoimport.memory` | `boolean` | Make the autoimport database memory only. Drastically increases startup time. | `false` | | `pylsp.plugins.rope_completion.enabled` | `boolean` | Enable or disable the plugin. | `false` | | `pylsp.plugins.rope_completion.eager` | `boolean` | Resolve documentation and detail eagerly. | `false` | diff --git a/external-deps/python-lsp-server/docs/autoimport.md b/external-deps/python-lsp-server/docs/autoimport.md index 5bf573e9e60..893a5e98ea8 100644 --- a/external-deps/python-lsp-server/docs/autoimport.md +++ b/external-deps/python-lsp-server/docs/autoimport.md @@ -4,6 +4,7 @@ Requirements: 1. install `python-lsp-server[rope]` 2. set `pylsp.plugins.rope_autoimport.enabled` to `true` +3. This enables both completions and code actions. You can switch them off by setting `pylsp.plugins.rope_autoimport.completions.enabled` and/or `pylsp.plugins.rope_autoimport.code_actions.enabled` to `false` ## Startup diff --git a/external-deps/python-lsp-server/pylsp/_utils.py b/external-deps/python-lsp-server/pylsp/_utils.py index 644a00e0246..9d393b92dab 100644 --- a/external-deps/python-lsp-server/pylsp/_utils.py +++ b/external-deps/python-lsp-server/pylsp/_utils.py @@ -209,7 +209,6 @@ def format_docstring( if markup_kind == "markdown": try: value = docstring_to_markdown.convert(contents) - return {"kind": "markdown", "value": value} except docstring_to_markdown.UnknownFormatError: # try to escape the Markdown syntax instead: value = escape_markdown(contents) diff --git a/external-deps/python-lsp-server/pylsp/config/config.py b/external-deps/python-lsp-server/pylsp/config/config.py index b458069bd74..454ee4b3989 100644 --- a/external-deps/python-lsp-server/pylsp/config/config.py +++ b/external-deps/python-lsp-server/pylsp/config/config.py @@ -98,6 +98,10 @@ def __init__(self, root_uri, init_opts, process_id, capabilities): self._plugin_settings, plugin_conf ) + self._plugin_settings = _utils.merge_dicts( + self._plugin_settings, self._init_opts.get("pylsp", {}) + ) + self._update_disabled_plugins() @property diff --git a/external-deps/python-lsp-server/pylsp/config/flake8_conf.py b/external-deps/python-lsp-server/pylsp/config/flake8_conf.py index 485945df21c..ca3b199ca73 100644 --- a/external-deps/python-lsp-server/pylsp/config/flake8_conf.py +++ b/external-deps/python-lsp-server/pylsp/config/flake8_conf.py @@ -24,6 +24,7 @@ ("select", "plugins.pycodestyle.select", list), # flake8 ("exclude", "plugins.flake8.exclude", list), + ("extend-ignore", "plugins.flake8.extendIgnore", list), ("filename", "plugins.flake8.filename", list), ("hang-closing", "plugins.flake8.hangClosing", bool), ("ignore", "plugins.flake8.ignore", list), diff --git a/external-deps/python-lsp-server/pylsp/config/schema.json b/external-deps/python-lsp-server/pylsp/config/schema.json index 4ac085d0fa2..ba1d36f8fc8 100644 --- a/external-deps/python-lsp-server/pylsp/config/schema.json +++ b/external-deps/python-lsp-server/pylsp/config/schema.json @@ -6,11 +6,16 @@ "properties": { "pylsp.configurationSources": { "type": "array", - "default": ["pycodestyle"], + "default": [ + "pycodestyle" + ], "description": "List of configuration sources to use.", "items": { "type": "string", - "enum": ["pycodestyle", "flake8"] + "enum": [ + "pycodestyle", + "flake8" + ] }, "uniqueItems": true }, @@ -20,7 +25,10 @@ "description": "Enable or disable the plugin (disabling required to use `yapf`)." }, "pylsp.plugins.flake8.config": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "description": "Path to the config file that will be the authoritative config source." }, @@ -37,18 +45,32 @@ }, "description": "List of files or directories to exclude." }, + "pylsp.plugins.flake8.extendIgnore": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "List of errors and warnings to append to ignore list." + }, "pylsp.plugins.flake8.executable": { "type": "string", "default": "flake8", "description": "Path to the flake8 executable." }, "pylsp.plugins.flake8.filename": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "description": "Only check for filenames matching the patterns in this list." }, "pylsp.plugins.flake8.hangClosing": { - "type": ["boolean", "null"], + "type": [ + "boolean", + "null" + ], "default": null, "description": "Hang closing bracket instead of matching indentation of opening bracket's line." }, @@ -66,17 +88,25 @@ "description": "Maximum allowed complexity threshold." }, "pylsp.plugins.flake8.maxLineLength": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null, "description": "Maximum allowed line length for the entirety of this run." }, "pylsp.plugins.flake8.indentSize": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null, "description": "Set indentation spaces." }, "pylsp.plugins.flake8.perFileIgnores": { - "type": ["array"], + "type": [ + "array" + ], "default": [], "items": { "type": "string" @@ -84,7 +114,10 @@ "description": "A pairing of filenames and violation codes that defines which violations to ignore in a particular file, for example: `[\"file_path.py:W305,W304\"]`)." }, "pylsp.plugins.flake8.select": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "default": null, "items": { "type": "string" @@ -94,7 +127,9 @@ }, "pylsp.plugins.jedi.auto_import_modules": { "type": "array", - "default": ["numpy"], + "default": [ + "numpy" + ], "items": { "type": "string" }, @@ -109,12 +144,18 @@ "description": "Define extra paths for jedi.Script." }, "pylsp.plugins.jedi.env_vars": { - "type": ["object", "null"], + "type": [ + "object", + "null" + ], "default": null, "description": "Define environment variables for jedi.Script and Jedi.names." }, "pylsp.plugins.jedi.environment": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "description": "Define environment for jedi.Script and Jedi.names." }, @@ -158,7 +199,12 @@ "items": { "type": "string" }, - "default": ["pandas", "numpy", "tensorflow", "matplotlib"], + "default": [ + "pandas", + "numpy", + "tensorflow", + "matplotlib" + ], "description": "Modules for which labels and snippets should be cached." }, "pylsp.plugins.jedi_definition.enabled": { @@ -259,7 +305,10 @@ "description": "When parsing directories, only check filenames matching these patterns." }, "pylsp.plugins.pycodestyle.select": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "default": null, "items": { "type": "string" @@ -277,17 +326,26 @@ "description": "Ignore errors and warnings" }, "pylsp.plugins.pycodestyle.hangClosing": { - "type": ["boolean", "null"], + "type": [ + "boolean", + "null" + ], "default": null, "description": "Hang closing bracket instead of matching indentation of opening bracket's line." }, "pylsp.plugins.pycodestyle.maxLineLength": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null, "description": "Set maximum allowed line length." }, "pylsp.plugins.pycodestyle.indentSize": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null, "description": "Set indentation spaces." }, @@ -297,9 +355,17 @@ "description": "Enable or disable the plugin." }, "pylsp.plugins.pydocstyle.convention": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, - "enum": ["pep257", "numpy", "google", null], + "enum": [ + "pep257", + "numpy", + "google", + null + ], "description": "Choose the basic list of checked errors by specifying an existing convention." }, "pylsp.plugins.pydocstyle.addIgnore": { @@ -330,7 +396,10 @@ "description": "Ignore errors and warnings" }, "pylsp.plugins.pydocstyle.select": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "default": null, "items": { "type": "string" @@ -368,14 +437,27 @@ "description": "Arguments to pass to pylint." }, "pylsp.plugins.pylint.executable": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "description": "Executable to run pylint with. Enabling this will run pylint on unsaved files via stdin. Can slow down workflow. Only works with python3." }, "pylsp.plugins.rope_autoimport.enabled": { "type": "boolean", "default": false, - "description": "Enable or disable autoimport." + "description": "Enable or disable autoimport. If false, neither completions nor code actions are enabled. If true, the respective features can be enabled or disabled individually." + }, + "pylsp.plugins.rope_autoimport.completions.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable autoimport completions." + }, + "pylsp.plugins.rope_autoimport.code_actions.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable autoimport code actions (e.g. for quick fixes)." }, "pylsp.plugins.rope_autoimport.memory": { "type": "boolean", @@ -398,12 +480,18 @@ "description": "Enable or disable the plugin." }, "pylsp.rope.extensionModules": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "description": "Builtin and c-extension modules that are allowed to be imported and inspected by rope." }, "pylsp.rope.ropeFolder": { - "type": ["array", "null"], + "type": [ + "array", + "null" + ], "default": null, "items": { "type": "string" @@ -412,4 +500,4 @@ "description": "The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all." } } -} +} \ No newline at end of file diff --git a/external-deps/python-lsp-server/pylsp/hookspecs.py b/external-deps/python-lsp-server/pylsp/hookspecs.py index d1a2458e6d3..9c9cf387880 100644 --- a/external-deps/python-lsp-server/pylsp/hookspecs.py +++ b/external-deps/python-lsp-server/pylsp/hookspecs.py @@ -25,7 +25,7 @@ def pylsp_commands(config, workspace): @hookspec -def pylsp_completions(config, workspace, document, position): +def pylsp_completions(config, workspace, document, position, ignored_names): pass @@ -127,3 +127,8 @@ def pylsp_settings(config): @hookspec(firstresult=True) def pylsp_signature_help(config, workspace, document, position): pass + + +@hookspec +def pylsp_workspace_configuration_changed(config, workspace): + pass diff --git a/external-deps/python-lsp-server/pylsp/plugins/definition.py b/external-deps/python-lsp-server/pylsp/plugins/definition.py index a5ccbd707eb..53eda915818 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/definition.py +++ b/external-deps/python-lsp-server/pylsp/plugins/definition.py @@ -1,21 +1,63 @@ # Copyright 2017-2020 Palantir Technologies, Inc. # Copyright 2021- Python Language Server Contributors. - +from __future__ import annotations import logging +from typing import Any, Dict, List, TYPE_CHECKING + +import jedi + from pylsp import hookimpl, uris, _utils +if TYPE_CHECKING: + from jedi.api import Script + from jedi.api.classes import Name + from pylsp.config.config import Config + from pylsp.workspace import Document + log = logging.getLogger(__name__) +MAX_JEDI_GOTO_HOPS = 100 + + +def _resolve_definition( + maybe_defn: Name, script: Script, settings: Dict[str, Any] +) -> Name: + for _ in range(MAX_JEDI_GOTO_HOPS): + if maybe_defn.is_definition() or maybe_defn.module_path != script.path: + break + defns = script.goto( + follow_imports=settings.get("follow_imports", True), + follow_builtin_imports=settings.get("follow_builtin_imports", True), + line=maybe_defn.line, + column=maybe_defn.column, + ) + if len(defns) == 1: + maybe_defn = defns[0] + else: + break + return maybe_defn + + @hookimpl -def pylsp_definitions(config, document, position): +def pylsp_definitions( + config: Config, document: Document, position: Dict[str, int] +) -> List[Dict[str, Any]]: settings = config.plugin_settings("jedi_definition") code_position = _utils.position_to_jedi_linecolumn(document, position) - definitions = document.jedi_script(use_document_path=True).goto( - follow_imports=settings.get("follow_imports", True), - follow_builtin_imports=settings.get("follow_builtin_imports", True), - **code_position, - ) + script = document.jedi_script(use_document_path=True) + auto_import_modules = jedi.settings.auto_import_modules + + try: + jedi.settings.auto_import_modules = [] + definitions = script.goto( + follow_imports=settings.get("follow_imports", True), + follow_builtin_imports=settings.get("follow_builtin_imports", True), + **code_position, + ) + definitions = [_resolve_definition(d, script, settings) for d in definitions] + finally: + jedi.settings.auto_import_modules = auto_import_modules follow_builtin_defns = settings.get("follow_builtin_definitions", True) return [ @@ -31,7 +73,7 @@ def pylsp_definitions(config, document, position): ] -def _not_internal_definition(definition): +def _not_internal_definition(definition: Name) -> bool: return ( definition.line is not None and definition.column is not None diff --git a/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py b/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py index d31783bf0fa..8d8d4c5f968 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py +++ b/external-deps/python-lsp-server/pylsp/plugins/flake8_lint.py @@ -58,6 +58,7 @@ def pylsp_lint(workspace, document): opts = { "config": settings.get("config"), "exclude": settings.get("exclude"), + "extend-ignore": settings.get("extendIgnore"), "filename": settings.get("filename"), "hang-closing": settings.get("hangClosing"), "ignore": ignores or None, diff --git a/external-deps/python-lsp-server/pylsp/plugins/jedi_rename.py b/external-deps/python-lsp-server/pylsp/plugins/jedi_rename.py index 700da508eb8..9c89c1dea28 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/jedi_rename.py +++ b/external-deps/python-lsp-server/pylsp/plugins/jedi_rename.py @@ -54,4 +54,6 @@ def pylsp_rename( def _num_lines(file_contents): "Count the number of lines in the given string." - return len(file_contents.splitlines()) + if _utils.get_eol_chars(file_contents): + return len(file_contents.splitlines()) + return 0 diff --git a/external-deps/python-lsp-server/pylsp/plugins/rope_autoimport.py b/external-deps/python-lsp-server/pylsp/plugins/rope_autoimport.py index be40fe4197d..ca3db1cfff4 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/rope_autoimport.py +++ b/external-deps/python-lsp-server/pylsp/plugins/rope_autoimport.py @@ -1,7 +1,7 @@ # Copyright 2022- Python Language Server Contributors. import logging -from typing import Any, Dict, Generator, List, Optional, Set +from typing import Any, Dict, Generator, List, Optional, Set, Union import parso from jedi import Script @@ -21,13 +21,27 @@ _score_pow = 5 _score_max = 10**_score_pow -MAX_RESULTS = 1000 +MAX_RESULTS_COMPLETIONS = 1000 +MAX_RESULTS_CODE_ACTIONS = 5 @hookimpl def pylsp_settings() -> Dict[str, Dict[str, Dict[str, Any]]]: # Default rope_completion to disabled - return {"plugins": {"rope_autoimport": {"enabled": False, "memory": False}}} + return { + "plugins": { + "rope_autoimport": { + "enabled": False, + "memory": False, + "completions": { + "enabled": True, + }, + "code_actions": { + "enabled": True, + }, + } + } + } # pylint: disable=too-many-return-statements @@ -122,6 +136,7 @@ def _process_statements( word: str, autoimport: AutoImport, document: Document, + feature: str = "completions", ) -> Generator[Dict[str, Any], None, None]: for suggestion in suggestions: insert_line = autoimport.find_insertion_line(document.source) - 1 @@ -134,14 +149,26 @@ def _process_statements( if score > _score_max: continue # TODO make this markdown - yield { - "label": suggestion.name, - "kind": suggestion.itemkind, - "sortText": _sort_import(score), - "data": {"doc_uri": doc_uri}, - "detail": _document(suggestion.import_statement), - "additionalTextEdits": [edit], - } + if feature == "completions": + yield { + "label": suggestion.name, + "kind": suggestion.itemkind, + "sortText": _sort_import(score), + "data": {"doc_uri": doc_uri}, + "detail": _document(suggestion.import_statement), + "additionalTextEdits": [edit], + } + elif feature == "code_actions": + yield { + "title": suggestion.import_statement, + "kind": "quickfix", + "edit": {"changes": {doc_uri: [edit]}}, + # data is a supported field for codeAction responses + # See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_codeAction # pylint: disable=line-too-long + "data": {"sortText": _sort_import(score)}, + } + else: + raise ValueError(f"Unknown feature: {feature}") def get_names(script: Script) -> Set[str]: @@ -153,9 +180,20 @@ def get_names(script: Script) -> Set[str]: @hookimpl def pylsp_completions( - config: Config, workspace: Workspace, document: Document, position + config: Config, + workspace: Workspace, + document: Document, + position, + ignored_names: Union[Set[str], None], ): """Get autoimport suggestions.""" + if ( + not config.plugin_settings("rope_autoimport") + .get("completions", {}) + .get("enabled", True) + ): + return [] + line = document.lines[position["line"]] expr = parso.parse(line) word_node = expr.get_leaf_for_position((1, position["character"])) @@ -164,17 +202,21 @@ def pylsp_completions( word = word_node.value log.debug(f"autoimport: searching for word: {word}") rope_config = config.settings(document_path=document.path).get("rope", {}) - ignored_names: Set[str] = get_names(document.jedi_script(use_document_path=True)) + ignored_names: Set[str] = ignored_names or get_names( + document.jedi_script(use_document_path=True) + ) autoimport = workspace._rope_autoimport(rope_config) suggestions = list(autoimport.search_full(word, ignored_names=ignored_names)) results = list( sorted( - _process_statements(suggestions, document.uri, word, autoimport, document), + _process_statements( + suggestions, document.uri, word, autoimport, document, "completions" + ), key=lambda statement: statement["sortText"], ) ) - if len(results) > MAX_RESULTS: - results = results[:MAX_RESULTS] + if len(results) > MAX_RESULTS_COMPLETIONS: + results = results[:MAX_RESULTS_COMPLETIONS] return results @@ -200,6 +242,83 @@ def _sort_import(score: int) -> str: return "[z" + str(score).rjust(_score_pow, "0") +def get_name_or_module(document, diagnostic) -> str: + start = diagnostic["range"]["start"] + return ( + parso.parse(document.lines[start["line"]]) + .get_leaf_for_position((1, start["character"] + 1)) + .value + ) + + +@hookimpl +def pylsp_code_actions( + config: Config, + workspace: Workspace, + document: Document, + range: Dict, # pylint: disable=redefined-builtin + context: Dict, +) -> List[Dict]: + """ + Provide code actions through rope. + + Parameters + ---------- + config : pylsp.config.config.Config + Current config. + workspace : pylsp.workspace.Workspace + Current workspace. + document : pylsp.workspace.Document + Document to apply code actions on. + range : Dict + Range argument given by pylsp. Not used here. + context : Dict + CodeActionContext given as dict. + + Returns + ------- + List of dicts containing the code actions. + """ + if ( + not config.plugin_settings("rope_autoimport") + .get("code_actions", {}) + .get("enabled", True) + ): + return [] + + log.debug(f"textDocument/codeAction: {document} {range} {context}") + code_actions = [] + for diagnostic in context.get("diagnostics", []): + if "undefined name" not in diagnostic.get("message", "").lower(): + continue + + word = get_name_or_module(document, diagnostic) + log.debug(f"autoimport: searching for word: {word}") + rope_config = config.settings(document_path=document.path).get("rope", {}) + autoimport = workspace._rope_autoimport(rope_config, feature="code_actions") + suggestions = list(autoimport.search_full(word)) + log.debug("autoimport: suggestions: %s", suggestions) + results = list( + sorted( + _process_statements( + suggestions, + document.uri, + word, + autoimport, + document, + "code_actions", + ), + key=lambda statement: statement["data"]["sortText"], + ) + ) + + if len(results) > MAX_RESULTS_CODE_ACTIONS: + results = results[:MAX_RESULTS_CODE_ACTIONS] + code_actions.extend(results) + + return code_actions + + def _reload_cache( config: Config, workspace: Workspace, files: Optional[List[Document]] = None ): @@ -238,3 +357,17 @@ def pylsp_document_did_open(config: Config, workspace: Workspace): def pylsp_document_did_save(config: Config, workspace: Workspace, document: Document): """Update the names associated with this document.""" _reload_cache(config, workspace, [document]) + + +@hookimpl +def pylsp_workspace_configuration_changed(config: Config, workspace: Workspace): + """ + Initialize autoimport if it has been enabled through a + workspace/didChangeConfiguration message from the frontend. + + Generates the cache for local and global items. + """ + if config.plugin_settings("rope_autoimport").get("enabled", False): + _reload_cache(config, workspace) + else: + log.debug("autoimport: Skipping cache reload.") diff --git a/external-deps/python-lsp-server/pylsp/plugins/rope_rename.py b/external-deps/python-lsp-server/pylsp/plugins/rope_rename.py index f59ba8909ef..a4323a420ee 100644 --- a/external-deps/python-lsp-server/pylsp/plugins/rope_rename.py +++ b/external-deps/python-lsp-server/pylsp/plugins/rope_rename.py @@ -6,7 +6,7 @@ from rope.base import libutils from rope.refactor.rename import Rename -from pylsp import hookimpl, uris +from pylsp import hookimpl, uris, _utils log = logging.getLogger(__name__) @@ -59,4 +59,8 @@ def pylsp_rename(config, workspace, document, position, new_name): def _num_lines(resource): "Count the number of lines in a `File` resource." - return len(resource.read().splitlines()) + text = resource.read() + + if _utils.get_eol_chars(text): + return len(text.splitlines()) + return 0 diff --git a/external-deps/python-lsp-server/pylsp/python_lsp.py b/external-deps/python-lsp-server/pylsp/python_lsp.py index 3eeb2f7f9fa..52a22a3e1bd 100644 --- a/external-deps/python-lsp-server/pylsp/python_lsp.py +++ b/external-deps/python-lsp-server/pylsp/python_lsp.py @@ -295,7 +295,7 @@ def capabilities(self): "openClose": True, }, "notebookDocumentSync": { - "notebookSelector": {"cells": [{"language": "python"}]} + "notebookSelector": [{"cells": [{"language": "python"}]}] }, "workspace": { "workspaceFolders": {"supported": True, "changeNotifications": True} @@ -385,7 +385,7 @@ def watch_parent_process(pid): def m_initialized(self, **_kwargs): self._hook("pylsp_initialized") - def code_actions(self, doc_uri, range, context): + def code_actions(self, doc_uri: str, range: Dict, context: Dict): return flatten( self._hook("pylsp_code_actions", doc_uri, range=range, context=context) ) @@ -394,7 +394,16 @@ def code_lens(self, doc_uri): return flatten(self._hook("pylsp_code_lens", doc_uri)) def completions(self, doc_uri, position): - completions = self._hook("pylsp_completions", doc_uri, position=position) + workspace = self._match_uri_to_workspace(doc_uri) + document = workspace.get_document(doc_uri) + ignored_names = None + if isinstance(document, Cell): + # We need to get the ignored names from the whole notebook document + notebook_document = workspace.get_maybe_document(document.notebook_uri) + ignored_names = notebook_document.jedi_names(doc_uri) + completions = self._hook( + "pylsp_completions", doc_uri, position=position, ignored_names=ignored_names + ) return {"isIncomplete": False, "items": flatten(completions)} def completion_item_resolve(self, completion_item): @@ -766,6 +775,7 @@ def m_workspace__did_change_configuration(self, settings=None): self.config.update((settings or {}).get("pylsp", {})) for workspace in self.workspaces.values(): workspace.update_config(settings) + self._hook("pylsp_workspace_configuration_changed") for doc_uri in workspace.documents: self.lint(doc_uri, is_saved=False) diff --git a/external-deps/python-lsp-server/pylsp/workspace.py b/external-deps/python-lsp-server/pylsp/workspace.py index 27af5f83c47..5c6880c9a72 100644 --- a/external-deps/python-lsp-server/pylsp/workspace.py +++ b/external-deps/python-lsp-server/pylsp/workspace.py @@ -8,7 +8,7 @@ import re import uuid import functools -from typing import Optional, Generator, Callable, List +from typing import Literal, Optional, Generator, Callable, List from threading import RLock import jedi @@ -58,16 +58,30 @@ def __init__(self, root_uri, endpoint, config=None): # Whilst incubating, keep rope private self.__rope = None self.__rope_config = None - self.__rope_autoimport = None - def _rope_autoimport(self, rope_config: Optional, memory: bool = False): + # We have a sperate AutoImport object for each feature to avoid sqlite errors + # from accessing the same database from multiple threads. + # An upstream fix discussion is here: https://github.com/python-rope/rope/issues/713 + self.__rope_autoimport = ( + {} + ) # Type: Dict[Literal["completions", "code_actions"], rope.contrib.autoimport.sqlite.AutoImport] + + def _rope_autoimport( + self, + rope_config: Optional, + memory: bool = False, + feature: Literal["completions", "code_actions"] = "completions", + ): # pylint: disable=import-outside-toplevel from rope.contrib.autoimport.sqlite import AutoImport - if self.__rope_autoimport is None: + if feature not in ["completions", "code_actions"]: + raise ValueError(f"Unknown feature {feature}") + + if self.__rope_autoimport.get(feature, None) is None: project = self._rope_project_builder(rope_config) - self.__rope_autoimport = AutoImport(project, memory=memory) - return self.__rope_autoimport + self.__rope_autoimport[feature] = AutoImport(project, memory=memory) + return self.__rope_autoimport[feature] def _rope_project_builder(self, rope_config): # pylint: disable=import-outside-toplevel @@ -167,7 +181,11 @@ def update_document(self, doc_uri, change, version=None): def update_config(self, settings): self._config.update((settings or {}).get("pylsp", {})) for doc_uri in self.documents: - self.get_document(doc_uri).update_config(settings) + if isinstance(document := self.get_document(doc_uri), Notebook): + # Notebook documents don't have a config. The config is + # handled at the cell level. + return + document.update_config(settings) def apply_edit(self, edit): return self._endpoint.request(self.M_APPLY_EDIT, {"edit": edit}) @@ -370,8 +388,8 @@ def _create_cell_document( ) def close(self): - if self.__rope_autoimport is not None: - self.__rope_autoimport.close() + for _, autoimport in self.__rope_autoimport.items(): + autoimport.close() class Document: @@ -591,6 +609,7 @@ def __init__( self.version = version self.cells = cells or [] self.metadata = metadata or {} + self._lock = RLock() def __str__(self): return "Notebook with URI '%s'" % str(self.uri) @@ -621,6 +640,31 @@ def cell_data(self): offset += num_lines return cell_data + @lock + def jedi_names( + self, + up_to_cell_uri: Optional[str] = None, + all_scopes=False, + definitions=True, + references=False, + ): + """ + Get the names in the notebook up to a certain cell. + + Parameters + ---------- + up_to_cell_uri: str, optional + The cell uri to stop at. If None, all cells are considered. + """ + names = set() + for cell in self.cells: + cell_uri = cell["document"] + cell_document = self.workspace.get_cell_document(cell_uri) + names.update(cell_document.jedi_names(all_scopes, definitions, references)) + if cell_uri == up_to_cell_uri: + break + return set(name.name for name in names) + class Cell(Document): """ diff --git a/external-deps/python-lsp-server/pyproject.toml b/external-deps/python-lsp-server/pyproject.toml index a27c1316bf0..1756952529a 100644 --- a/external-deps/python-lsp-server/pyproject.toml +++ b/external-deps/python-lsp-server/pyproject.toml @@ -27,13 +27,13 @@ Homepage = "https://github.com/python-lsp/python-lsp-server" [project.optional-dependencies] all = [ - "autopep8>=1.6.0,<2.1.0", + "autopep8>=2.0.4,<2.1.0", "flake8>=6.1.0,<7", "mccabe>=0.7.0,<0.8.0", "pycodestyle>=2.11.0,<2.12.0", "pydocstyle>=6.3.0,<6.4.0", "pyflakes>=3.1.0,<3.2.0", - "pylint>=2.5.0,<3", + "pylint>=2.5.0,<3.1", "rope>1.2.0", "yapf>=0.33.0", "whatthepatch>=1.0.2,<2.0.0" @@ -44,12 +44,12 @@ mccabe = ["mccabe>=0.7.0,<0.8.0"] pycodestyle = ["pycodestyle>=2.11.0,<2.12.0"] pydocstyle = ["pydocstyle>=6.3.0,<6.4.0"] pyflakes = ["pyflakes>=3.1.0,<3.2.0"] -pylint = ["pylint>=2.5.0,<3"] +pylint = ["pylint>=2.5.0,<3.1"] rope = ["rope>1.2.0"] yapf = ["yapf>=0.33.0", "whatthepatch>=1.0.2,<2.0.0"] websockets = ["websockets>=10.3"] test = [ - "pylint>=2.5.0,<3", + "pylint>=2.5.0,<3.1", "pytest", "pytest-cov", "coverage", diff --git a/external-deps/python-lsp-server/test/fixtures.py b/external-deps/python-lsp-server/test/fixtures.py index 03d0f8243d3..11c302b041f 100644 --- a/external-deps/python-lsp-server/test/fixtures.py +++ b/external-deps/python-lsp-server/test/fixtures.py @@ -5,9 +5,10 @@ from io import StringIO from unittest.mock import MagicMock -from test.test_utils import ClientServerPair +from test.test_utils import ClientServerPair, CALL_TIMEOUT_IN_SECONDS import pytest + from pylsp_jsonrpc.dispatchers import MethodDispatcher from pylsp_jsonrpc.endpoint import Endpoint from pylsp_jsonrpc.exceptions import JsonRpcException @@ -24,7 +25,6 @@ def main(): print sys.stdin.read() """ -CALL_TIMEOUT_IN_SECONDS = 30 class FakeEditorMethodsMixin: diff --git a/external-deps/python-lsp-server/test/plugins/test_autoimport.py b/external-deps/python-lsp-server/test/plugins/test_autoimport.py index dbb6f7a43c7..ec5c0a33f97 100644 --- a/external-deps/python-lsp-server/test/plugins/test_autoimport.py +++ b/external-deps/python-lsp-server/test/plugins/test_autoimport.py @@ -1,6 +1,6 @@ # Copyright 2022- Python Language Server Contributors. -from typing import Dict, List +from typing import Any, Dict, List from unittest.mock import Mock import jedi @@ -9,16 +9,29 @@ from pylsp import lsp, uris from pylsp.config.config import Config -from pylsp.plugins.rope_autoimport import _get_score, _should_insert, get_names +from pylsp.plugins.rope_autoimport import ( + _get_score, + _should_insert, + get_name_or_module, + get_names, +) from pylsp.plugins.rope_autoimport import ( pylsp_completions as pylsp_autoimport_completions, ) from pylsp.plugins.rope_autoimport import pylsp_initialize from pylsp.workspace import Workspace + DOC_URI = uris.from_fs_path(__file__) +def contains_autoimport(suggestion: Dict[str, Any], module: str) -> bool: + """Checks if `suggestion` contains an autoimport for `module`.""" + return suggestion.get("label", "") == module and "import" in suggestion.get( + "detail", "" + ) + + @pytest.fixture(scope="session") def autoimport_workspace(tmp_path_factory) -> Workspace: "Special autoimport workspace. Persists across sessions to make in-memory sqlite3 database fast." @@ -26,7 +39,16 @@ def autoimport_workspace(tmp_path_factory) -> Workspace: uris.from_fs_path(str(tmp_path_factory.mktemp("pylsp"))), Mock() ) workspace._config = Config(workspace.root_uri, {}, 0, {}) - workspace._config.update({"rope_autoimport": {"memory": True, "enabled": True}}) + workspace._config.update( + { + "rope_autoimport": { + "memory": True, + "enabled": True, + "completions": {"enabled": True}, + "code_actions": {"enabled": True}, + } + } + ) pylsp_initialize(workspace._config, workspace) yield workspace workspace.close() @@ -39,7 +61,9 @@ def completions(config: Config, autoimport_workspace: Workspace, request): com_position = {"line": 0, "character": position} autoimport_workspace.put_document(DOC_URI, source=document) doc = autoimport_workspace.get_document(DOC_URI) - yield pylsp_autoimport_completions(config, autoimport_workspace, doc, com_position) + yield pylsp_autoimport_completions( + config, autoimport_workspace, doc, com_position, None + ) autoimport_workspace.rm_document(DOC_URI) @@ -141,45 +165,13 @@ def test_autoimport_defined_name(config, workspace): com_position = {"line": 1, "character": 3} workspace.put_document(DOC_URI, source=document) doc = workspace.get_document(DOC_URI) - completions = pylsp_autoimport_completions(config, workspace, doc, com_position) + completions = pylsp_autoimport_completions( + config, workspace, doc, com_position, None + ) workspace.rm_document(DOC_URI) assert not check_dict({"label": "List"}, completions) -# This test has several large issues. -# 1. autoimport relies on its sources being written to disk. This makes testing harder -# 2. the hook doesn't handle removed files -# 3. The testing framework cannot access the actual autoimport object so it cannot clear the cache -# def test_autoimport_update_module(config: Config, workspace: Workspace): -# document2 = "SomethingYouShouldntWrite = 1" -# document = """SomethingYouShouldntWrit""" -# com_position = { -# "line": 0, -# "character": 3, -# } -# doc2_path = workspace.root_path + "/test_file_no_one_should_write_to.py" -# if os.path.exists(doc2_path): -# os.remove(doc2_path) -# DOC2_URI = uris.from_fs_path(doc2_path) -# workspace.put_document(DOC_URI, source=document) -# doc = workspace.get_document(DOC_URI) -# completions = pylsp_autoimport_completions(config, workspace, doc, com_position) -# assert len(completions) == 0 -# with open(doc2_path, "w") as f: -# f.write(document2) -# workspace.put_document(DOC2_URI, source=document2) -# doc2 = workspace.get_document(DOC2_URI) -# pylsp_document_did_save(config, workspace, doc2) -# assert check_dict({"label": "SomethingYouShouldntWrite"}, completions) -# workspace.put_document(DOC2_URI, source="\n") -# doc2 = workspace.get_document(DOC2_URI) -# os.remove(doc2_path) -# pylsp_document_did_save(config, workspace, doc2) -# completions = pylsp_autoimport_completions(config, workspace, doc, com_position) -# assert len(completions) == 0 -# workspace.rm_document(DOC_URI) - - class TestShouldInsert: def test_dot(self): assert not should_insert("""str.""", 4) @@ -233,3 +225,105 @@ class sfa: """ results = get_names(jedi.Script(code=source)) assert results == set(["blah", "bleh", "e", "hello", "someone", "sfa", "a", "b"]) + + +# Tests ruff, flake8 and pyflakes messages +@pytest.mark.parametrize( + "message", + ["Undefined name `os`", "F821 undefined name 'numpy'", "undefined name 'numpy'"], +) +def test_autoimport_code_actions_get_correct_module_name(autoimport_workspace, message): + source = "os.path.join('a', 'b')" + autoimport_workspace.put_document(DOC_URI, source=source) + doc = autoimport_workspace.get_document(DOC_URI) + diagnostic = { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 2}, + }, + "message": message, + } + module_name = get_name_or_module(doc, diagnostic) + autoimport_workspace.rm_document(DOC_URI) + assert module_name == "os" + + +# rope autoimport launches a sqlite database which checks from which thread it is called. +# This makes the test below fail because we access the db from a different thread. +# See https://stackoverflow.com/questions/48218065/objects-created-in-a-thread-can-only-be-used-in-that-same-thread +# @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") +# def test_autoimport_completions_for_notebook_document( +# client_server_pair, +# ): +# client, server = client_server_pair +# send_initialize_request(client) + +# with patch.object(server._endpoint, "notify") as mock_notify: +# # Expectations: +# # 1. We receive an autoimport suggestion for "os" in the first cell because +# # os is imported after that. +# # 2. We don't receive an autoimport suggestion for "os" in the second cell because it's +# # already imported in the second cell. +# # 3. We don't receive an autoimport suggestion for "os" in the third cell because it's +# # already imported in the second cell. +# # 4. We receive an autoimport suggestion for "sys" because it's not already imported +# send_notebook_did_open(client, ["os", "import os\nos", "os", "sys"]) +# wait_for_condition(lambda: mock_notify.call_count >= 3) + +# server.m_workspace__did_change_configuration( +# settings={ +# "pylsp": { +# "plugins": { +# "rope_autoimport": { +# "memory": True, +# "completions": {"enabled": True}, +# }, +# } +# } +# } +# ) +# rope_autoimport_settings = server.workspace._config.plugin_settings( +# "rope_autoimport" +# ) +# assert rope_autoimport_settings.get("completions", {}).get("enabled", False) is True +# assert rope_autoimport_settings.get("memory", False) is True + +# # 1. +# suggestions = server.completions("cell_1_uri", {"line": 0, "character": 2}).get( +# "items" +# ) +# assert any( +# suggestion +# for suggestion in suggestions +# if contains_autoimport(suggestion, "os") +# ) + +# # 2. +# suggestions = server.completions("cell_2_uri", {"line": 1, "character": 2}).get( +# "items" +# ) +# assert not any( +# suggestion +# for suggestion in suggestions +# if contains_autoimport(suggestion, "os") +# ) + +# # 3. +# suggestions = server.completions("cell_3_uri", {"line": 0, "character": 2}).get( +# "items" +# ) +# assert not any( +# suggestion +# for suggestion in suggestions +# if contains_autoimport(suggestion, "os") +# ) + +# # 4. +# suggestions = server.completions("cell_4_uri", {"line": 0, "character": 3}).get( +# "items" +# ) +# assert any( +# suggestion +# for suggestion in suggestions +# if contains_autoimport(suggestion, "sys") +# ) diff --git a/external-deps/python-lsp-server/test/plugins/test_definitions.py b/external-deps/python-lsp-server/test/plugins/test_definitions.py index 34acc6a9de1..c366e8ca9cf 100644 --- a/external-deps/python-lsp-server/test/plugins/test_definitions.py +++ b/external-deps/python-lsp-server/test/plugins/test_definitions.py @@ -12,7 +12,7 @@ DOC = """def a(): pass -print a() +print(a()) class Directory(object): @@ -21,6 +21,24 @@ def __init__(self): def add_member(self, id, name): self.members[id] = name + + +subscripted_before_reference = {} +subscripted_before_reference[0] = 0 +subscripted_before_reference + + +def my_func(): + print('called') + +alias = my_func +my_list = [1, None, alias] +inception = my_list[2] + +inception() + +import numpy +numpy.ones """ @@ -40,6 +58,49 @@ def test_definitions(config, workspace): ) +def test_indirect_definitions(config, workspace): + # Over 'subscripted_before_reference' + cursor_pos = {"line": 16, "character": 0} + + # The definition of 'subscripted_before_reference', + # skipping intermediate writes to the most recent definition + def_range = { + "start": {"line": 14, "character": 0}, + "end": {"line": 14, "character": len("subscripted_before_reference")}, + } + + doc = Document(DOC_URI, workspace, DOC) + assert [{"uri": DOC_URI, "range": def_range}] == pylsp_definitions( + config, doc, cursor_pos + ) + + +def test_definition_with_multihop_inference_goto(config, workspace): + # Over 'inception()' + cursor_pos = {"line": 26, "character": 0} + + # The most recent definition of 'inception', + # ignoring alias hops + def_range = { + "start": {"line": 24, "character": 0}, + "end": {"line": 24, "character": len("inception")}, + } + + doc = Document(DOC_URI, workspace, DOC) + assert [{"uri": DOC_URI, "range": def_range}] == pylsp_definitions( + config, doc, cursor_pos + ) + + +def test_numpy_definition(config, workspace): + # Over numpy.ones + cursor_pos = {"line": 29, "character": 8} + + doc = Document(DOC_URI, workspace, DOC) + defns = pylsp_definitions(config, doc, cursor_pos) + assert len(defns) > 0, defns + + def test_builtin_definition(config, workspace): # Over 'i' in dict cursor_pos = {"line": 8, "character": 24} diff --git a/external-deps/python-lsp-server/test/plugins/test_jedi_rename.py b/external-deps/python-lsp-server/test/plugins/test_jedi_rename.py index c3a1e485da6..d88a929719d 100644 --- a/external-deps/python-lsp-server/test/plugins/test_jedi_rename.py +++ b/external-deps/python-lsp-server/test/plugins/test_jedi_rename.py @@ -2,16 +2,12 @@ # Copyright 2021- Python Language Server Contributors. import os -import sys import pytest from pylsp import uris from pylsp.plugins.jedi_rename import pylsp_rename from pylsp.workspace import Document -LT_PY36 = sys.version_info.major < 3 or ( - sys.version_info.major == 3 and sys.version_info.minor < 6 -) DOC_NAME = "test1.py" DOC = """class Test1(): @@ -26,13 +22,17 @@ class Test2(Test1): x = Test1() """ +DOC_NAME_SIMPLE = "test3.py" +DOC_SIMPLE = "foo = 12" + @pytest.fixture def tmp_workspace(temp_workspace_factory): - return temp_workspace_factory({DOC_NAME: DOC, DOC_NAME_EXTRA: DOC_EXTRA}) + return temp_workspace_factory( + {DOC_NAME: DOC, DOC_NAME_EXTRA: DOC_EXTRA, DOC_NAME_SIMPLE: DOC_SIMPLE} + ) -@pytest.mark.skipif(LT_PY36, reason="Jedi refactoring isnt supported on Python 2.x/3.5") def test_jedi_rename(tmp_workspace, config): # pylint: disable=redefined-outer-name # rename the `Test1` class position = {"line": 0, "character": 6} @@ -56,6 +56,7 @@ def test_jedi_rename(tmp_workspace, config): # pylint: disable=redefined-outer- "newText": "class ShouldBeRenamed():\n pass\n\nclass Test2(ShouldBeRenamed):\n pass\n", } ] + path = os.path.join(tmp_workspace.root_path, DOC_NAME_EXTRA) uri_extra = uris.from_fs_path(path) assert changes[1]["textDocument"]["uri"] == uri_extra @@ -63,6 +64,7 @@ def test_jedi_rename(tmp_workspace, config): # pylint: disable=redefined-outer- # but that do need to be renamed in the project have a `null` version # number. assert changes[1]["textDocument"]["version"] is None + expected = "from test1 import ShouldBeRenamed\nx = ShouldBeRenamed()\n" if os.name == "nt": # The .write method in the temp_workspace_factory functions writes @@ -77,3 +79,27 @@ def test_jedi_rename(tmp_workspace, config): # pylint: disable=redefined-outer- "newText": expected, } ] + + # Regression test for issue python-lsp/python-lsp-server#413 + # rename foo + position = {"line": 0, "character": 0} + DOC_URI = uris.from_fs_path(os.path.join(tmp_workspace.root_path, DOC_NAME_SIMPLE)) + doc = Document(DOC_URI, tmp_workspace) + + result = pylsp_rename(config, tmp_workspace, doc, position, "bar") + assert len(result.keys()) == 1 + + changes = result.get("documentChanges") + assert len(changes) == 1 + + assert changes[0]["textDocument"]["uri"] == doc.uri + assert changes[0]["textDocument"]["version"] == doc.version + assert changes[0].get("edits") == [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 0}, + }, + "newText": "bar = 12", + } + ] diff --git a/external-deps/python-lsp-server/test/plugins/test_rope_rename.py b/external-deps/python-lsp-server/test/plugins/test_rope_rename.py index 285a565e293..9b9039baf40 100644 --- a/external-deps/python-lsp-server/test/plugins/test_rope_rename.py +++ b/external-deps/python-lsp-server/test/plugins/test_rope_rename.py @@ -8,6 +8,7 @@ from pylsp.plugins.rope_rename import pylsp_rename from pylsp.workspace import Document + DOC_NAME = "test1.py" DOC = """class Test1(): pass @@ -16,10 +17,13 @@ class Test2(Test1): pass """ +DOC_NAME_SIMPLE = "test2.py" +DOC_SIMPLE = "foo = 12" + @pytest.fixture def tmp_workspace(temp_workspace_factory): - return temp_workspace_factory({DOC_NAME: DOC}) + return temp_workspace_factory({DOC_NAME: DOC, DOC_NAME_SIMPLE: DOC_SIMPLE}) def test_rope_rename(tmp_workspace, config): # pylint: disable=redefined-outer-name @@ -45,3 +49,25 @@ def test_rope_rename(tmp_workspace, config): # pylint: disable=redefined-outer- "newText": "class ShouldBeRenamed():\n pass\n\nclass Test2(ShouldBeRenamed):\n pass\n", } ] + + # Regression test for issue python-lsp/python-lsp-server#413 + # rename foo + position = {"line": 0, "character": 0} + DOC_URI = uris.from_fs_path(os.path.join(tmp_workspace.root_path, DOC_NAME_SIMPLE)) + doc = Document(DOC_URI, tmp_workspace) + + result = pylsp_rename(config, tmp_workspace, doc, position, "bar") + assert len(result.keys()) == 1 + + changes = result.get("documentChanges") + assert len(changes) == 1 + + assert changes[0].get("edits") == [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 0}, + }, + "newText": "bar = 12", + } + ] diff --git a/external-deps/python-lsp-server/test/test_configuration.py b/external-deps/python-lsp-server/test/test_configuration.py new file mode 100644 index 00000000000..91da421211b --- /dev/null +++ b/external-deps/python-lsp-server/test/test_configuration.py @@ -0,0 +1,53 @@ +# Copyright 2021- Python Language Server Contributors. + +from unittest.mock import patch + +from test.test_utils import send_initialize_request +from test.test_notebook_document import wait_for_condition + +import pytest + +from pylsp import IS_WIN + + +INITIALIZATION_OPTIONS = { + "pylsp": { + "plugins": { + "flake8": {"enabled": True}, + "pycodestyle": {"enabled": False}, + "pyflakes": {"enabled": False}, + }, + } +} + + +@pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") +def test_set_flake8_using_init_opts(client_server_pair): + client, server = client_server_pair + send_initialize_request(client, INITIALIZATION_OPTIONS) + for key, value in INITIALIZATION_OPTIONS["pylsp"]["plugins"].items(): + assert server.workspace._config.settings().get("plugins").get(key).get( + "enabled" + ) == value.get("enabled") + + +@pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") +def test_set_flake8_using_workspace_did_change_configuration(client_server_pair): + client, server = client_server_pair + send_initialize_request(client, None) + assert ( + server.workspace._config.settings().get("plugins").get("flake8").get("enabled") + is False + ) + + with patch.object(server.workspace, "update_config") as mock_update_config: + client._endpoint.notify( + "workspace/didChangeConfiguration", + {"settings": INITIALIZATION_OPTIONS}, + ) + wait_for_condition(lambda: mock_update_config.call_count >= 1) + + for key, value in INITIALIZATION_OPTIONS["pylsp"]["plugins"].items(): + assert server.workspace._config.settings().get("plugins").get(key).get( + "enabled" + ) == value.get("enabled") diff --git a/external-deps/python-lsp-server/test/test_language_server.py b/external-deps/python-lsp-server/test/test_language_server.py index 280a62faead..401b1ceb260 100644 --- a/external-deps/python-lsp-server/test/test_language_server.py +++ b/external-deps/python-lsp-server/test/test_language_server.py @@ -5,7 +5,7 @@ import time import sys -from test.test_utils import ClientServerPair +from test.test_utils import ClientServerPair, send_initialize_request from flaky import flaky from pylsp_jsonrpc.exceptions import JsonRpcMethodNotFound @@ -73,14 +73,7 @@ def test_not_exit_without_check_parent_process_flag( client_server_pair, ): client, _ = client_server_pair - response = client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + response = send_initialize_request(client) assert "capabilities" in response diff --git a/external-deps/python-lsp-server/test/test_notebook_document.py b/external-deps/python-lsp-server/test/test_notebook_document.py index e8e7ac75fa4..c63d2791ebf 100644 --- a/external-deps/python-lsp-server/test/test_notebook_document.py +++ b/external-deps/python-lsp-server/test/test_notebook_document.py @@ -1,12 +1,16 @@ # Copyright 2021- Python Language Server Contributors. -import os import time from unittest.mock import patch, call -from test.fixtures import CALL_TIMEOUT_IN_SECONDS +from test.test_utils import ( + CALL_TIMEOUT_IN_SECONDS, + send_initialize_request, + send_notebook_did_open, +) import pytest +from pylsp.workspace import Notebook from pylsp import IS_WIN from pylsp.lsp import NotebookCellKind @@ -24,31 +28,18 @@ def wait_for_condition(condition, timeout=CALL_TIMEOUT_IN_SECONDS): @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") def test_initialize(client_server_pair): client, server = client_server_pair - response = client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + response = send_initialize_request(client) assert server.workspace is not None - assert "notebookDocumentSync" in response["capabilities"].keys() + selector = response["capabilities"]["notebookDocumentSync"]["notebookSelector"] + assert isinstance(selector, list) @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") -def test_notebook_document__did_open( - client_server_pair, -): +def test_workspace_did_change_configuration(client_server_pair): + """Test that we can update a workspace config w/o error when a notebook is open.""" client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) + assert server.workspace is not None with patch.object(server._endpoint, "notify") as mock_notify: client._endpoint.notify( @@ -62,54 +53,46 @@ def test_notebook_document__did_open( "kind": NotebookCellKind.Code, "document": "cell_1_uri", }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_3_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_4_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_5_uri", - }, ], }, - # Test as many edge cases as possible for the diagnostics message "cellTextDocuments": [ { "uri": "cell_1_uri", "languageId": "python", "text": "", }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "\n", - }, - { - "uri": "cell_3_uri", - "languageId": "python", - "text": "\nimport sys\n\nabc\n\n", - }, - { - "uri": "cell_4_uri", - "languageId": "python", - "text": "x", - }, - { - "uri": "cell_5_uri", - "languageId": "python", - "text": "y\n", - }, ], }, ) + wait_for_condition(lambda: mock_notify.call_count >= 1) + assert isinstance(server.workspace.get_document("notebook_uri"), Notebook) + assert len(server.workspace.documents) == 2 + + server.workspace.update_config( + {"pylsp": {"plugins": {"flake8": {"enabled": True}}}} + ) + + assert server.config.plugin_settings("flake8").get("enabled") is True + assert ( + server.workspace.get_document("cell_1_uri") + ._config.plugin_settings("flake8") + .get("enabled") + is True + ) + + +@pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") +def test_notebook_document__did_open( + client_server_pair, +): + client, server = client_server_pair + send_initialize_request(client) + + with patch.object(server._endpoint, "notify") as mock_notify: + # Test as many edge cases as possible for the diagnostics messages + send_notebook_did_open( + client, ["", "\n", "\nimport sys\n\nabc\n\n", "x", "y\n"] + ) wait_for_condition(lambda: mock_notify.call_count >= 5) expected_call_args = [ call( @@ -205,48 +188,11 @@ def test_notebook_document__did_change( client_server_pair, ): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) # Open notebook with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - ], - }, - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "import sys", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "", - }, - ], - }, - ) + send_notebook_did_open(client, ["import sys", ""]) wait_for_condition(lambda: mock_notify.call_count >= 2) assert len(server.workspace.documents) == 3 for uri in ["cell_1_uri", "cell_2_uri", "notebook_uri"]: @@ -477,48 +423,11 @@ def test_notebook__did_close( client_server_pair, ): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) # Open notebook with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - ], - }, - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "import sys", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "", - }, - ], - }, - ) + send_notebook_did_open(client, ["import sys", ""]) wait_for_condition(lambda: mock_notify.call_count >= 2) assert len(server.workspace.documents) == 3 for uri in ["cell_1_uri", "cell_2_uri", "notebook_uri"]: @@ -549,48 +458,11 @@ def test_notebook__did_close( @pytest.mark.skipif(IS_WIN, reason="Flaky on Windows") def test_notebook_definition(client_server_pair): client, server = client_server_pair - client._endpoint.request( - "initialize", - { - "processId": 1234, - "rootPath": os.path.dirname(__file__), - "initializationOptions": {}, - }, - ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + send_initialize_request(client) # Open notebook with patch.object(server._endpoint, "notify") as mock_notify: - client._endpoint.notify( - "notebookDocument/didOpen", - { - "notebookDocument": { - "uri": "notebook_uri", - "notebookType": "jupyter-notebook", - "cells": [ - { - "kind": NotebookCellKind.Code, - "document": "cell_1_uri", - }, - { - "kind": NotebookCellKind.Code, - "document": "cell_2_uri", - }, - ], - }, - "cellTextDocuments": [ - { - "uri": "cell_1_uri", - "languageId": "python", - "text": "y=2\nx=1", - }, - { - "uri": "cell_2_uri", - "languageId": "python", - "text": "x", - }, - ], - }, - ) + send_notebook_did_open(client, ["y=2\nx=1", "x"]) # wait for expected diagnostics messages wait_for_condition(lambda: mock_notify.call_count >= 2) assert len(server.workspace.documents) == 3 diff --git a/external-deps/python-lsp-server/test/test_utils.py b/external-deps/python-lsp-server/test/test_utils.py index 7d01ba01c64..8b518d72669 100644 --- a/external-deps/python-lsp-server/test/test_utils.py +++ b/external-deps/python-lsp-server/test/test_utils.py @@ -6,14 +6,73 @@ import sys from threading import Thread import time +from typing import Any, Dict, List from unittest import mock from flaky import flaky +from docstring_to_markdown import UnknownFormatError from pylsp import _utils +from pylsp.lsp import NotebookCellKind from pylsp.python_lsp import PythonLSPServer, start_io_lang_server +CALL_TIMEOUT_IN_SECONDS = 30 + + +def send_notebook_did_open(client, cells: List[str]): + """ + Sends a notebookDocument/didOpen notification with the given python cells. + + The notebook has the uri "notebook_uri" and the cells have the uris + "cell_1_uri", "cell_2_uri", etc. + """ + client._endpoint.notify( + "notebookDocument/didOpen", notebook_with_python_cells(cells) + ) + + +def notebook_with_python_cells(cells: List[str]): + """ + Create a notebook document with the given python cells. + + The notebook has the uri "notebook_uri" and the cells have the uris + "cell_1_uri", "cell_2_uri", etc. + """ + return { + "notebookDocument": { + "uri": "notebook_uri", + "notebookType": "jupyter-notebook", + "cells": [ + { + "kind": NotebookCellKind.Code, + "document": f"cell_{i+1}_uri", + } + for i in range(len(cells)) + ], + }, + "cellTextDocuments": [ + { + "uri": f"cell_{i+1}_uri", + "languageId": "python", + "text": cell, + } + for i, cell in enumerate(cells) + ], + } + + +def send_initialize_request(client, initialization_options: Dict[str, Any] = None): + return client._endpoint.request( + "initialize", + { + "processId": 1234, + "rootPath": os.path.dirname(__file__), + "initializationOptions": initialization_options, + }, + ).result(timeout=CALL_TIMEOUT_IN_SECONDS) + + def start(obj): obj.start() @@ -154,3 +213,53 @@ def test_clip_column(): assert _utils.clip_column(2, ["123\n", "123"], 0) == 2 assert _utils.clip_column(3, ["123\n", "123"], 0) == 3 assert _utils.clip_column(4, ["123\n", "123"], 1) == 3 + + +@mock.patch("docstring_to_markdown.convert") +def test_format_docstring_valid_rst_signature(mock_convert): + """Test that a valid RST docstring includes the function signature.""" + docstring = """A function docstring. + + Parameters + ---------- + a : str, something + """ + + # Mock the return value to avoid depedency on the real thing + mock_convert.return_value = """A function docstring. + + #### Parameters + + - `a`: str, something + """ + + markdown = _utils.format_docstring( + docstring, + "markdown", + ["something(a: str) -> str"], + )["value"] + + assert markdown.startswith( + _utils.wrap_signature("something(a: str) -> str"), + ) + + +@mock.patch("docstring_to_markdown.convert", side_effect=UnknownFormatError) +def test_format_docstring_invalid_rst_signature(_): + """Test that an invalid RST docstring includes the function signature.""" + docstring = """A function docstring. + + Parameters + ---------- + a : str, something + """ + + markdown = _utils.format_docstring( + docstring, + "markdown", + ["something(a: str) -> str"], + )["value"] + + assert markdown.startswith( + _utils.wrap_signature("something(a: str) -> str"), + ) diff --git a/requirements/main.yml b/requirements/main.yml index 59073b14aa8..5531a68113a 100644 --- a/requirements/main.yml +++ b/requirements/main.yml @@ -25,7 +25,7 @@ dependencies: - pickleshare >=0.4 - psutil >=5.3 - pygments >=2.0 - - pylint >=2.5.0,<3.0 + - pylint >=2.5.0,<3.1 - pylint-venv >=3.0.2 - pyls-spyder >=0.4.0 - pyqt <5.16 diff --git a/setup.py b/setup.py index f83faecc663..37c2b88c4b7 100644 --- a/setup.py +++ b/setup.py @@ -225,7 +225,7 @@ def run(self): 'pickleshare>=0.4', 'psutil>=5.3', 'pygments>=2.0', - 'pylint>=2.5.0,<3.0', + 'pylint>=2.5.0,<3.1', 'pylint-venv>=3.0.2', 'pyls-spyder>=0.4.0', 'pyqt5<5.16', diff --git a/spyder/dependencies.py b/spyder/dependencies.py index ea17760c6b6..0c54708e610 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -54,7 +54,7 @@ PICKLESHARE_REQVER = '>=0.4' PSUTIL_REQVER = '>=5.3' PYGMENTS_REQVER = '>=2.0' -PYLINT_REQVER = '>=2.5.0,<3.0' +PYLINT_REQVER = '>=2.5.0,<3.1' PYLINT_VENV_REQVER = '>=3.0.2' PYLSP_REQVER = '>=1.8.0,<1.9.0' PYLSP_BLACK_REQVER = '>=1.2.0,<3.0.0' diff --git a/spyder/plugins/pylint/utils.py b/spyder/plugins/pylint/utils.py index f933fcf0da4..ac01d119815 100644 --- a/spyder/plugins/pylint/utils.py +++ b/spyder/plugins/pylint/utils.py @@ -24,7 +24,9 @@ def _find_pylintrc_path(path): if pylint_config is not None: os.chdir(path) - return pylint_config.find_pylintrc() + for p in pylint_config.find_default_config_files(): + # return the first config found as str + return str(p) def get_pylintrc_path(search_paths=None, home_path=None):