diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c02c5c37..c42d0fab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - PYTHON_VERSION: ['3.8'] + PYTHON_VERSION: ['3.9'] timeout-minutes: 10 steps: - uses: actions/cache@v1 diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 6ec4345d..881a0aa6 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -30,9 +30,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - # TODO: check with Python 3, but need to fix the - # errors first - python-version: '3.8' + python-version: '3.9' architecture: 'x64' - run: python -m pip install --upgrade pip setuptools jsonschema # If we don't install pycodestyle, pylint will throw an unused-argument error in pylsp/plugins/pycodestyle_lint.py:72 diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index 89277d67..7a7f2f6e 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - PYTHON_VERSION: ['3.10', '3.9', '3.8'] + PYTHON_VERSION: ['3.11', '3.10', '3.9'] timeout-minutes: 10 steps: - uses: actions/cache@v4 diff --git a/.github/workflows/test-mac.yml b/.github/workflows/test-mac.yml index d9e4818f..a92c82a8 100644 --- a/.github/workflows/test-mac.yml +++ b/.github/workflows/test-mac.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - PYTHON_VERSION: ['3.10', '3.9', '3.8'] + PYTHON_VERSION: ['3.11', '3.10', '3.9'] timeout-minutes: 10 steps: - uses: actions/cache@v4 diff --git a/.github/workflows/test-win.yml b/.github/workflows/test-win.yml index 1db41154..8ecd3429 100644 --- a/.github/workflows/test-win.yml +++ b/.github/workflows/test-win.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - PYTHON_VERSION: ['3.10', '3.9', '3.8'] + PYTHON_VERSION: ['3.11', '3.10', '3.9'] timeout-minutes: 10 steps: - uses: actions/cache@v4 diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls new file mode 100644 index 00000000..dc9cf163 --- /dev/null +++ b/.well-known/funding-manifest-urls @@ -0,0 +1 @@ +https://www.spyder-ide.org/funding.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5a5c2d..146f6730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # History of changes +## Version 1.12.2 (2025/02/07) + +### Pull Requests Merged + +* [PR 608](https://github.com/python-lsp/python-lsp-server/pull/608) - Fix putting `extra_paths` in front of `sys.path`, by [@cmashinho](https://github.com/cmashinho) + +In this release 1 pull request was closed. + +---- + ## Version 1.12.1 (2025/02/06) ### Issues Closed diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0609169b..ec2a9a6c 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -42,6 +42,7 @@ This server can be configured using the `workspace/didChangeConfiguration` metho | `pylsp.plugins.jedi_symbols.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.plugins.jedi_symbols.all_scopes` | `boolean` | If True lists the names of all scopes instead of only the module namespace. | `true` | | `pylsp.plugins.jedi_symbols.include_import_symbols` | `boolean` | If True includes symbols imported from other libraries. | `true` | +| `pylsp.plugins.jedi_type_definition.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.plugins.mccabe.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.plugins.mccabe.threshold` | `integer` | The minimum threshold that triggers warnings about cyclomatic complexity. | `15` | | `pylsp.plugins.preload.enabled` | `boolean` | Enable or disable the plugin. | `true` | @@ -75,5 +76,7 @@ This server can be configured using the `workspace/didChangeConfiguration` metho | `pylsp.plugins.yapf.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.rope.extensionModules` | `string` | Builtin and c-extension modules that are allowed to be imported and inspected by rope. | `null` | | `pylsp.rope.ropeFolder` | `array` of unique `string` items | The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all. | `null` | +| `pylsp.signature.formatter` | `string` (one of: `'black'`, `'ruff'`, `None`) | Formatter to use for reformatting signatures in docstrings. | `"black"` | +| `pylsp.signature.line_length` | `number` | Maximum line length in signatures. | `88` | This documentation was generated from `pylsp/config/schema.json`. Please do not edit this file directly. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..eb6e57df --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + + +## Supported Versions + +We normally support only the most recently released version with bug fixes, security updates and compatibility improvements. + + +## Reporting a Vulnerability + +If you believe you've discovered a security vulnerability in this project, please open a new security advisory with [our GitHub repo's private vulnerability reporting](https://github.com/python-lsp/python-lsp-server/security/advisories/new). +Please be sure to carefully document the vulnerability, including a summary, describing the impacts, identifying the line(s) of code affected, stating the conditions under which it is exploitable and including a minimal reproducible test case. +Further information and advice or patches on how to mitigate it is always welcome. +You can usually expect to hear back within 1 week, at which point we'll inform you of our evaluation of the vulnerability and what steps we plan to take, and will reach out if we need further clarification from you. +We'll discuss and update the advisory thread, and are happy to update you on its status should you further inquire. +While this is a volunteer project and we don't have financial compensation to offer, we can certainly publicly thank and credit you for your help if you would like. +Thanks! diff --git a/pylsp/__main__.py b/pylsp/__main__.py index 44aa3cfa..760f8829 100644 --- a/pylsp/__main__.py +++ b/pylsp/__main__.py @@ -20,7 +20,7 @@ start_ws_lang_server, ) -LOG_FORMAT = "%(asctime)s {0} - %(levelname)s - %(name)s - %(message)s".format( +LOG_FORMAT = "%(asctime)s {} - %(levelname)s - %(name)s - %(message)s".format( time.localtime().tm_zone ) @@ -40,7 +40,7 @@ def add_arguments(parser) -> None: "--check-parent-process", action="store_true", help="Check whether parent process is still alive using os.kill(ppid, 0) " - "and auto shut down language server process when parent process is not alive." + "and auto shut down language server process when parent process is not alive. " "Note that this may not work on a Windows machine.", ) @@ -50,7 +50,7 @@ def add_arguments(parser) -> None: ) log_group.add_argument( "--log-file", - help="Redirect logs to the given file instead of writing to stderr." + help="Redirect logs to the given file instead of writing to stderr. " "Has no effect if used with --log-config.", ) @@ -98,7 +98,7 @@ def _configure_logger(verbose=0, log_config=None, log_file=None) -> None: root_logger = logging.root if log_config: - with open(log_config, "r", encoding="utf-8") as f: + with open(log_config, encoding="utf-8") as f: logging.config.dictConfig(json.load(f)) else: formatter = logging.Formatter(LOG_FORMAT) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index b96df5a9..dfe84b14 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -7,9 +7,11 @@ import os import pathlib import re +import subprocess +import sys import threading import time -from typing import List, Optional +from typing import Optional import docstring_to_markdown import jedi @@ -57,7 +59,7 @@ def run(): def throttle(seconds=1): - """Throttles calls to a function evey `seconds` seconds.""" + """Throttles calls to a function every `seconds` seconds.""" def decorator(func): @functools.wraps(func) @@ -78,7 +80,7 @@ def find_parents(root, path, names): Args: path (str): The file path to start searching up from. - names (List[str]): The file/directory names to look for. + names (list[str]): The file/directory names to look for. root (str): The directory at which to stop recursing upwards. Note: @@ -198,7 +200,7 @@ def wrap_signature(signature): SERVER_SUPPORTED_MARKUP_KINDS = {"markdown", "plaintext"} -def choose_markup_kind(client_supported_markup_kinds: List[str]): +def choose_markup_kind(client_supported_markup_kinds: list[str]): """Choose a markup kind supported by both client and the server. This gives priority to the markup kinds provided earlier on the client preference list. @@ -209,8 +211,96 @@ def choose_markup_kind(client_supported_markup_kinds: List[str]): return "markdown" +class Formatter: + command: list[str] + + @property + def is_installed(self) -> bool: + """Returns whether formatter is available""" + if not hasattr(self, "_is_installed"): + self._is_installed = self._is_available_via_cli() + return self._is_installed + + def format(self, code: str, line_length: int) -> str: + """Formats code""" + return subprocess.check_output( + [ + sys.executable, + "-m", + *self.command, + "--line-length", + str(line_length), + "-", + ], + input=code, + text=True, + ).strip() + + def _is_available_via_cli(self) -> bool: + try: + subprocess.check_output( + [ + sys.executable, + "-m", + *self.command, + "--help", + ], + ) + return True + except subprocess.CalledProcessError: + return False + + +class RuffFormatter(Formatter): + command = ["ruff", "format"] + + +class BlackFormatter(Formatter): + command = ["black"] + + +formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()} + + +def format_signature(signature: str, config: dict, signature_formatter: str) -> str: + """Formats signature using ruff or black if either is available.""" + as_func = f"def {signature.strip()}:\n pass" + line_length = config.get("line_length", 88) + formatter = formatters[signature_formatter] + if formatter.is_installed: + try: + return ( + formatter.format(as_func, line_length=line_length) + .removeprefix("def ") + .removesuffix(":\n pass") + ) + except subprocess.CalledProcessError as e: + log.warning("Signature formatter failed %s", e) + else: + log.warning( + "Formatter %s was requested but it does not appear to be installed", + signature_formatter, + ) + return signature + + +def convert_signatures_to_markdown(signatures: list[str], config: dict) -> str: + signature_formatter = config.get("formatter", "black") + if signature_formatter: + signatures = [ + format_signature( + signature, signature_formatter=signature_formatter, config=config + ) + for signature in signatures + ] + return wrap_signature("\n".join(signatures)) + + def format_docstring( - contents: str, markup_kind: str, signatures: Optional[List[str]] = None + contents: str, + markup_kind: str, + signatures: Optional[list[str]] = None, + signature_config: Optional[dict] = None, ): """Transform the provided docstring into a MarkupContent object. @@ -232,7 +322,10 @@ def format_docstring( value = escape_markdown(contents) if signatures: - value = wrap_signature("\n".join(signatures)) + "\n\n" + value + wrapped_signatures = convert_signatures_to_markdown( + signatures, config=signature_config or {} + ) + value = wrapped_signatures + "\n\n" + value return {"kind": "markdown", "value": value} value = contents diff --git a/pylsp/config/config.py b/pylsp/config/config.py index 815f8fd2..7b201824 100644 --- a/pylsp/config/config.py +++ b/pylsp/config/config.py @@ -3,8 +3,9 @@ import logging import sys +from collections.abc import Mapping, Sequence from functools import lru_cache -from typing import List, Mapping, Sequence, Union +from typing import Union import pluggy from pluggy._hooks import HookImpl @@ -32,7 +33,7 @@ def _hookexec( methods: Sequence[HookImpl], kwargs: Mapping[str, object], firstresult: bool, - ) -> Union[object, List[object]]: + ) -> Union[object, list[object]]: # called from all hookcaller instances. # enable_tracing will set its own wrapping function at self._inner_hookexec try: diff --git a/pylsp/config/schema.json b/pylsp/config/schema.json index 18248384..a0caa38a 100644 --- a/pylsp/config/schema.json +++ b/pylsp/config/schema.json @@ -270,6 +270,11 @@ "default": true, "description": "If True includes symbols imported from other libraries." }, + "pylsp.plugins.jedi_type_definition.enabled": { + "type": "boolean", + "default": true, + "description": "Enable or disable the plugin." + }, "pylsp.plugins.mccabe.enabled": { "type": "boolean", "default": true, @@ -511,6 +516,24 @@ }, "uniqueItems": true, "description": "The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all." + }, + "pylsp.signature.formatter": { + "type": [ + "string", + "null" + ], + "enum": [ + "black", + "ruff", + null + ], + "default": "black", + "description": "Formatter to use for reformatting signatures in docstrings." + }, + "pylsp.signature.line_length": { + "type": "number", + "default": 88, + "description": "Maximum line length in signatures." } } } diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py index 41508be1..e7e7ce42 100644 --- a/pylsp/hookspecs.py +++ b/pylsp/hookspecs.py @@ -38,6 +38,11 @@ def pylsp_definitions(config, workspace, document, position) -> None: pass +@hookspec(firstresult=True) +def pylsp_type_definition(config, document, position): + pass + + @hookspec def pylsp_dispatchers(config, workspace) -> None: pass diff --git a/pylsp/plugins/_resolvers.py b/pylsp/plugins/_resolvers.py index 44d6d882..dcfd06ab 100644 --- a/pylsp/plugins/_resolvers.py +++ b/pylsp/plugins/_resolvers.py @@ -88,7 +88,7 @@ def resolve(self, completion): def format_label(completion, sig): if sig and completion.type in ("function", "method"): params = ", ".join(param.name for param in sig[0].params) - label = "{}({})".format(completion.name, params) + label = f"{completion.name}({params})" return label return completion.name @@ -115,7 +115,7 @@ def format_snippet(completion, sig): snippet_completion["insertTextFormat"] = lsp.InsertTextFormat.Snippet snippet = completion.name + "(" for i, param in enumerate(positional_args): - snippet += "${%s:%s}" % (i + 1, param.name) + snippet += "${{{}:{}}}".format(i + 1, param.name) if i < len(positional_args) - 1: snippet += ", " snippet += ")$0" diff --git a/pylsp/plugins/_rope_task_handle.py b/pylsp/plugins/_rope_task_handle.py index 8bc13c1d..5e278ee5 100644 --- a/pylsp/plugins/_rope_task_handle.py +++ b/pylsp/plugins/_rope_task_handle.py @@ -1,7 +1,8 @@ from __future__ import annotations import logging -from typing import Callable, ContextManager, List, Optional, Sequence +from collections.abc import Sequence +from typing import Callable, ContextManager from rope.base.taskhandle import BaseJobSet, BaseTaskHandle @@ -19,13 +20,13 @@ class PylspJobSet(BaseJobSet): _report_iter: ContextManager job_name: str = "" - def __init__(self, count: Optional[int], report_iter: ContextManager) -> None: + def __init__(self, count: int | None, report_iter: ContextManager) -> None: if count is not None: self.count = count self._reporter = report_iter.__enter__() self._report_iter = report_iter - def started_job(self, name: Optional[str]) -> None: + def started_job(self, name: str | None) -> None: if name: self.job_name = name @@ -42,7 +43,7 @@ def finished_job(self) -> None: def check_status(self) -> None: pass - def get_percent_done(self) -> Optional[float]: + def get_percent_done(self) -> float | None: if self.count == 0: return 0 return (self.done / self.count) * 100 @@ -66,8 +67,8 @@ def _report(self) -> None: class PylspTaskHandle(BaseTaskHandle): name: str - observers: List - job_sets: List[PylspJobSet] + observers: list + job_sets: list[PylspJobSet] stopped: bool workspace: Workspace _report: Callable[[str, str], None] @@ -77,7 +78,7 @@ def __init__(self, workspace: Workspace) -> None: self.job_sets = [] self.observers = [] - def create_jobset(self, name="JobSet", count: Optional[int] = None): + def create_jobset(self, name="JobSet", count: int | None = None): report_iter = self.workspace.report_progress( name, None, None, skip_token_initialization=True ) @@ -89,7 +90,7 @@ def create_jobset(self, name="JobSet", count: Optional[int] = None): def stop(self) -> None: pass - def current_jobset(self) -> Optional[BaseJobSet]: + def current_jobset(self) -> BaseJobSet | None: pass def add_observer(self) -> None: diff --git a/pylsp/plugins/definition.py b/pylsp/plugins/definition.py index 67abfb71..1ddc03a0 100644 --- a/pylsp/plugins/definition.py +++ b/pylsp/plugins/definition.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any import jedi @@ -23,7 +23,7 @@ def _resolve_definition( - maybe_defn: Name, script: Script, settings: Dict[str, Any] + 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: @@ -43,8 +43,8 @@ def _resolve_definition( @hookimpl def pylsp_definitions( - config: Config, document: Document, position: Dict[str, int] -) -> List[Dict[str, Any]]: + 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) script = document.jedi_script(use_document_path=True) diff --git a/pylsp/plugins/flake8_lint.py b/pylsp/plugins/flake8_lint.py index 74e2664c..0ac91855 100644 --- a/pylsp/plugins/flake8_lint.py +++ b/pylsp/plugins/flake8_lint.py @@ -135,7 +135,7 @@ def run_flake8(flake8_executable, args, document, source): cmd = [flake8_executable] cmd.extend(args) p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE, **popen_kwargs) - except IOError: + except OSError: log.debug( "Can't execute %s. Trying with '%s -m flake8'", flake8_executable, @@ -165,9 +165,9 @@ def build_args(options): arg = "--{}={}".format(arg_name, ",".join(arg_val)) elif isinstance(arg_val, bool): if arg_val: - arg = "--{}".format(arg_name) + arg = f"--{arg_name}" else: - arg = "--{}={}".format(arg_name, arg_val) + arg = f"--{arg_name}={arg_val}" args.append(arg) return args diff --git a/pylsp/plugins/hover.py b/pylsp/plugins/hover.py index ca69d1b3..daaae90b 100644 --- a/pylsp/plugins/hover.py +++ b/pylsp/plugins/hover.py @@ -10,6 +10,7 @@ @hookimpl def pylsp_hover(config, document, position): + signature_config = config.settings().get("signature", {}) code_position = _utils.position_to_jedi_linecolumn(document, position) definitions = document.jedi_script(use_document_path=True).infer(**code_position) word = document.word_at_position(position) @@ -46,5 +47,6 @@ def pylsp_hover(config, document, position): definition.docstring(raw=True), preferred_markup_kind, signatures=[signature] if signature else None, + signature_config=signature_config, ) } diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 2796a093..51c3589c 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -40,8 +40,9 @@ def pylsp_completions(config, document, position): """Get formatted completions for current code position""" settings = config.plugin_settings("jedi_completion", document_path=document.path) resolve_eagerly = settings.get("eager", False) - code_position = _utils.position_to_jedi_linecolumn(document, position) + signature_config = config.settings().get("signature", {}) + code_position = _utils.position_to_jedi_linecolumn(document, position) code_position["fuzzy"] = settings.get("fuzzy", False) completions = document.jedi_script(use_document_path=True).complete(**code_position) @@ -88,6 +89,7 @@ def pylsp_completions(config, document, position): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, + signature_config=signature_config, ) for i, c in enumerate(completions) ] @@ -103,6 +105,7 @@ def pylsp_completions(config, document, position): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, + signature_config=signature_config, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -118,6 +121,7 @@ def pylsp_completions(config, document, position): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, + signature_config=signature_config, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -137,7 +141,11 @@ def pylsp_completions(config, document, position): @hookimpl -def pylsp_completion_item_resolve(config, completion_item, document): +def pylsp_completion_item_resolve( + config, + completion_item, + document, +): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get( completion_item["label"] @@ -152,7 +160,12 @@ def pylsp_completion_item_resolve(config, completion_item, document): if shared_data: completion, data = shared_data - return _resolve_completion(completion, data, markup_kind=preferred_markup_kind) + return _resolve_completion( + completion, + data, + markup_kind=preferred_markup_kind, + signature_config=config.settings().get("signature", {}), + ) return completion_item @@ -207,13 +220,14 @@ def use_snippets(document, position): return expr_type not in _IMPORTS and not (expr_type in _ERRORS and "import" in code) -def _resolve_completion(completion, d, markup_kind: str): +def _resolve_completion(completion, d, markup_kind: str, signature_config: dict): completion["detail"] = _detail(d) try: docs = _utils.format_docstring( d.docstring(raw=True), signatures=[signature.to_string() for signature in d.get_signatures()], markup_kind=markup_kind, + signature_config=signature_config, ) except Exception: docs = "" @@ -228,6 +242,7 @@ def _format_completion( resolve=False, resolve_label_or_snippet=False, snippet_support=False, + signature_config=None, ): completion = { "label": _label(d, resolve_label_or_snippet), @@ -237,7 +252,9 @@ def _format_completion( } if resolve: - completion = _resolve_completion(completion, d, markup_kind) + completion = _resolve_completion( + completion, d, markup_kind, signature_config=signature_config + ) # Adjustments for file completions if d.type == "path": diff --git a/pylsp/plugins/pylint_lint.py b/pylsp/plugins/pylint_lint.py index 722e831b..f3415c8a 100644 --- a/pylsp/plugins/pylint_lint.py +++ b/pylsp/plugins/pylint_lint.py @@ -287,7 +287,7 @@ def _run_pylint_stdio(pylint_executable, document, flags): cmd.extend(flags) cmd.extend(["--from-stdin", document.path]) p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) - except IOError: + except OSError: log.debug("Can't execute %s. Trying with 'python -m pylint'", pylint_executable) cmd = [sys.executable, "-m", "pylint"] cmd.extend(flags) diff --git a/pylsp/plugins/rope_autoimport.py b/pylsp/plugins/rope_autoimport.py index 12f5d80b..8ba951f7 100644 --- a/pylsp/plugins/rope_autoimport.py +++ b/pylsp/plugins/rope_autoimport.py @@ -2,7 +2,8 @@ import logging import threading -from typing import Any, Dict, Generator, List, Optional, Set, Union +from collections.abc import Generator +from typing import Any, Optional, Union import parso from jedi import Script @@ -36,7 +37,7 @@ def reload_cache( self, config: Config, workspace: Workspace, - files: Optional[List[Document]] = None, + files: Optional[list[Document]] = None, single_thread: Optional[bool] = True, ): if self.is_blocked(): @@ -45,7 +46,7 @@ def reload_cache( memory: bool = config.plugin_settings("rope_autoimport").get("memory", False) rope_config = config.settings().get("rope", {}) autoimport = workspace._rope_autoimport(rope_config, memory) - resources: Optional[List[Resource]] = ( + resources: Optional[list[Resource]] = ( None if files is None else [document._rope_resource(rope_config) for document in files] @@ -65,7 +66,7 @@ def _reload_cache( self, workspace: Workspace, autoimport: AutoImport, - resources: Optional[List[Resource]] = None, + resources: Optional[list[Resource]] = None, ) -> None: task_handle = PylspTaskHandle(workspace) autoimport.generate_cache(task_handle=task_handle, resources=resources) @@ -76,7 +77,7 @@ def is_blocked(self): @hookimpl -def pylsp_settings() -> Dict[str, Dict[str, Dict[str, Any]]]: +def pylsp_settings() -> dict[str, dict[str, dict[str, Any]]]: # Default rope_completion to disabled return { "plugins": { @@ -180,13 +181,13 @@ def _handle_argument(node: NodeOrLeaf, word_node: tree.Leaf): def _process_statements( - suggestions: List[SearchResult], + suggestions: list[SearchResult], doc_uri: str, word: str, autoimport: AutoImport, document: Document, feature: str = "completions", -) -> Generator[Dict[str, Any], None, None]: +) -> Generator[dict[str, Any], None, None]: for suggestion in suggestions: insert_line = autoimport.find_insertion_line(document.source) - 1 start = {"line": insert_line, "character": 0} @@ -220,7 +221,7 @@ def _process_statements( raise ValueError(f"Unknown feature: {feature}") -def get_names(script: Script) -> Set[str]: +def get_names(script: Script) -> set[str]: """Get all names to ignore from the current file.""" raw_names = script.get_names(definitions=True) log.debug(raw_names) @@ -233,7 +234,7 @@ def pylsp_completions( workspace: Workspace, document: Document, position, - ignored_names: Union[Set[str], None], + ignored_names: Union[set[str], None], ): """Get autoimport suggestions.""" if ( @@ -251,7 +252,7 @@ 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] = ignored_names or get_names( + ignored_names: set[str] = ignored_names or get_names( document.jedi_script(use_document_path=True) ) autoimport = workspace._rope_autoimport(rope_config) @@ -303,9 +304,9 @@ def pylsp_code_actions( config: Config, workspace: Workspace, document: Document, - range: Dict, - context: Dict, -) -> List[Dict]: + range: dict, + context: dict, +) -> list[dict]: """ Provide code actions through rope. @@ -317,9 +318,9 @@ def pylsp_code_actions( Current workspace. document : pylsp.workspace.Document Document to apply code actions on. - range : Dict + range : dict Range argument given by pylsp. Not used here. - context : Dict + context : dict CodeActionContext given as dict. Returns diff --git a/pylsp/plugins/rope_completion.py b/pylsp/plugins/rope_completion.py index b3a1f066..dc94ddea 100644 --- a/pylsp/plugins/rope_completion.py +++ b/pylsp/plugins/rope_completion.py @@ -22,7 +22,7 @@ def _resolve_completion(completion, data, markup_kind): except Exception as e: log.debug("Failed to resolve Rope completion: %s", e) doc = "" - completion["detail"] = "{0} {1}".format(data.scope or "", data.name) + completion["detail"] = "{} {}".format(data.scope or "", data.name) completion["documentation"] = doc return completion diff --git a/pylsp/plugins/symbols.py b/pylsp/plugins/symbols.py index 4e1890c1..3a7beb07 100644 --- a/pylsp/plugins/symbols.py +++ b/pylsp/plugins/symbols.py @@ -2,6 +2,7 @@ # Copyright 2021- Python Language Server Contributors. import logging +import re from pathlib import Path from pylsp import hookimpl @@ -19,6 +20,9 @@ def pylsp_document_symbols(config, document): symbols = [] exclude = set({}) redefinitions = {} + pattern_import = re.compile( + r"^\s*(?!#)\s*(from\s+[.\w]+(\.[\w]+)*\s+import\s+[\w\s,()*]+|import\s+[\w\s,.*]+)" + ) while definitions != []: d = definitions.pop(0) @@ -27,7 +31,8 @@ def pylsp_document_symbols(config, document): if not add_import_symbols: # Skip if there's an import in the code the symbol is defined. code = d.get_line_code() - if " import " in code or "import " in code: + + if pattern_import.match(code): continue # Skip imported symbols comparing module names. diff --git a/pylsp/plugins/type_definition.py b/pylsp/plugins/type_definition.py new file mode 100644 index 00000000..5fe0a890 --- /dev/null +++ b/pylsp/plugins/type_definition.py @@ -0,0 +1,38 @@ +# Copyright 2021- Python Language Server Contributors. + +import logging + +from pylsp import _utils, hookimpl + +log = logging.getLogger(__name__) + + +def lsp_location(name): + module_path = name.module_path + if module_path is None or name.line is None or name.column is None: + return None + uri = module_path.as_uri() + return { + "uri": str(uri), + "range": { + "start": {"line": name.line - 1, "character": name.column}, + "end": {"line": name.line - 1, "character": name.column + len(name.name)}, + }, + } + + +@hookimpl +def pylsp_type_definition(config, document, position): + try: + kwargs = _utils.position_to_jedi_linecolumn(document, position) + script = document.jedi_script() + names = script.infer(**kwargs) + definitions = [ + definition + for definition in [lsp_location(name) for name in names] + if definition is not None + ] + return definitions + except Exception as e: + log.debug("Failed to run type_definition: %s", e) + return [] diff --git a/pylsp/py.typed b/pylsp/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index ba41d6aa..36265890 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -7,7 +7,7 @@ import threading import uuid from functools import partial -from typing import Any, Dict, List +from typing import Any try: import ujson as json @@ -117,6 +117,8 @@ def start_ws_lang_server(port, check_parent_process, handler_class) -> None: ) from e with ThreadPoolExecutor(max_workers=10) as tpool: + send_queue = None + loop = None async def pylsp_ws(websocket): log.debug("Creating LSP object") @@ -146,14 +148,20 @@ def send_message(message, websocket): """Handler to send responses of processed requests to respective web socket clients""" try: payload = json.dumps(message, ensure_ascii=False) - asyncio.run(websocket.send(payload)) + loop.call_soon_threadsafe(send_queue.put_nowait, (payload, websocket)) except Exception as e: log.exception("Failed to write message %s, %s", message, str(e)) async def run_server(): + nonlocal send_queue, loop + send_queue = asyncio.Queue() + loop = asyncio.get_running_loop() + async with websockets.serve(pylsp_ws, port=port): - # runs forever - await asyncio.Future() + while 1: + # Wait until payload is available for sending + payload, websocket = await send_queue.get() + await websocket.send(payload) asyncio.run(run_server()) @@ -276,6 +284,7 @@ def capabilities(self): "documentRangeFormattingProvider": True, "documentSymbolProvider": True, "definitionProvider": True, + "typeDefinitionProvider": True, "executeCommandProvider": { "commands": flatten(self._hook("pylsp_commands")) }, @@ -382,7 +391,7 @@ def watch_parent_process(pid): def m_initialized(self, **_kwargs) -> None: self._hook("pylsp_initialized") - def code_actions(self, doc_uri: str, range: Dict, context: Dict): + def code_actions(self, doc_uri: str, range: dict, context: dict): return flatten( self._hook("pylsp_code_actions", doc_uri, range=range, context=context) ) @@ -412,6 +421,9 @@ def completion_item_resolve(self, completion_item): def definitions(self, doc_uri, position): return flatten(self._hook("pylsp_definitions", doc_uri, position=position)) + def type_definition(self, doc_uri, position): + return self._hook("pylsp_type_definition", doc_uri, position=position) + def document_symbols(self, doc_uri): return flatten(self._hook("pylsp_document_symbols", doc_uri)) @@ -471,7 +483,7 @@ def _lint_notebook_document(self, notebook_document, workspace) -> None: random_uri = str(uuid.uuid4()) # cell_list helps us map the diagnostics back to the correct cell later. - cell_list: List[Dict[str, Any]] = [] + cell_list: list[dict[str, Any]] = [] offset = 0 total_source = "" @@ -762,6 +774,11 @@ def m_text_document__definition(self, textDocument=None, position=None, **_kwarg return self._cell_document__definition(document, position, **_kwargs) return self.definitions(textDocument["uri"], position) + def m_text_document__type_definition( + self, textDocument=None, position=None, **_kwargs + ): + return self.type_definition(textDocument["uri"], position) + def m_text_document__document_highlight( self, textDocument=None, position=None, **_kwargs ): diff --git a/pylsp/uris.py b/pylsp/uris.py index cba5b290..8ebd8e31 100644 --- a/pylsp/uris.py +++ b/pylsp/uris.py @@ -61,7 +61,7 @@ def to_fs_path(uri): if netloc and path and scheme == "file": # unc path: file://shares/c$/far/boo - value = "//{}{}".format(netloc, path) + value = f"//{netloc}{path}" elif RE_DRIVE_LETTER_PATH.match(path): # windows drive letter: file:///C:/far/boo diff --git a/pylsp/workspace.py b/pylsp/workspace.py index d8f1bfd6..290b95ee 100644 --- a/pylsp/workspace.py +++ b/pylsp/workspace.py @@ -7,9 +7,10 @@ import os import re import uuid +from collections.abc import Generator from contextlib import contextmanager from threading import RLock -from typing import Callable, Generator, List, Optional +from typing import Callable, Optional import jedi @@ -436,7 +437,7 @@ def lines(self): @lock def source(self): if self._source is None: - with io.open(self.path, "r", encoding="utf-8") as f: + with open(self.path, encoding="utf-8") as f: return f.read() return self._source @@ -460,7 +461,8 @@ def apply_change(self, change): end_col = change_range["end"]["character"] # Check for an edit occuring at the very end of the file - if start_line == len(self.lines): + lines = self.lines + if start_line == len(lines): self._source = self.source + text return @@ -469,7 +471,7 @@ def apply_change(self, change): # Iterate over the existing document until we hit the edit range, # at which point we write the new text, then loop until we hit # the end of the range and continue writing. - for i, line in enumerate(self.lines): + for i, line in enumerate(lines): if i < start_line: new.write(line) continue @@ -493,10 +495,11 @@ def offset_at_position(self, position): def word_at_position(self, position): """Get the word under the cursor returning the start and end positions.""" - if position["line"] >= len(self.lines): + lines = self.lines + if position["line"] >= len(lines): return "" - line = self.lines[position["line"]] + line = lines[position["line"]] i = position["character"] # Split word in two start = line[:i] @@ -599,9 +602,9 @@ def sys_path( ) path.extend(environment.get_sys_path()) if prioritize_extra_paths: - path += extra_paths + path + path = extra_paths + path else: - path += path + extra_paths + path = path + extra_paths return path @@ -623,7 +626,7 @@ def __init__( def __str__(self): return "Notebook with URI '%s'" % str(self.uri) - def add_cells(self, new_cells: List, start: int) -> None: + def add_cells(self, new_cells: list, start: int) -> None: self.cells[start:start] = new_cells def remove_cells(self, start: int, delete_count: int) -> None: diff --git a/pyproject.toml b/pyproject.toml index f9c6a521..7b0c205b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ # Copyright 2021- Python Language Server Contributors. [build-system] -requires = ["setuptools>=61.2.0", "setuptools_scm[toml]>=3.4.3"] +requires = ["setuptools>=69.0.0", "setuptools_scm[toml]>=3.4.3"] build-backend = "setuptools.build_meta" [project] @@ -11,7 +11,7 @@ authors = [{name = "Python Language Server Contributors"}] description = "Python Language Server for the Language Server Protocol" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "docstring-to-markdown", "importlib_metadata>=4.8.3;python_version<\"3.10\"", @@ -19,6 +19,7 @@ dependencies = [ "pluggy>=1.0.0", "python-lsp-jsonrpc>=1.1.0,<2.0.0", "ujson>=3.0.0", + "black" ] dynamic = ["version"] @@ -58,6 +59,7 @@ test = [ "matplotlib", "pyqt5", "flaky", + "websockets>=10.3", ] [project.entry-points.pylsp] @@ -66,6 +68,7 @@ folding = "pylsp.plugins.folding" flake8 = "pylsp.plugins.flake8_lint" jedi_completion = "pylsp.plugins.jedi_completion" jedi_definition = "pylsp.plugins.definition" +jedi_type_definition = "pylsp.plugins.type_definition" jedi_hover = "pylsp.plugins.hover" jedi_highlight = "pylsp.plugins.highlight" jedi_references = "pylsp.plugins.references" @@ -120,8 +123,8 @@ exclude = [ line-length = 88 indent-width = 4 -# Assume Python 3.8 -target-version = "py38" +# Assume Python 3.9 +target-version = "py39" [tool.ruff.lint] # https://docs.astral.sh/ruff/rules/ diff --git a/test/plugins/test_autoimport.py b/test/plugins/test_autoimport.py index dbad8d02..cbe3dde1 100644 --- a/test/plugins/test_autoimport.py +++ b/test/plugins/test_autoimport.py @@ -1,6 +1,6 @@ # Copyright 2022- Python Language Server Contributors. -from typing import Any, Dict, List +from typing import Any from unittest.mock import Mock, patch import jedi @@ -26,14 +26,14 @@ DOC_URI = uris.from_fs_path(__file__) -def contains_autoimport_completion(suggestion: Dict[str, Any], module: str) -> bool: +def contains_autoimport_completion(suggestion: dict[str, Any], module: str) -> bool: """Checks if `suggestion` contains an autoimport completion for `module`.""" return suggestion.get("label", "") == module and "import" in suggestion.get( "detail", "" ) -def contains_autoimport_quickfix(suggestion: Dict[str, Any], module: str) -> bool: +def contains_autoimport_quickfix(suggestion: dict[str, Any], module: str) -> bool: """Checks if `suggestion` contains an autoimport quick fix for `module`.""" return suggestion.get("title", "") == f"import {module}" @@ -78,7 +78,7 @@ def should_insert(phrase: str, position: int): return _should_insert(expr, word_node) -def check_dict(query: Dict, results: List[Dict]) -> bool: +def check_dict(query: dict, results: list[dict]) -> bool: for result in results: if all(result[key] == query[key] for key in query.keys()): return True diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py index b8de8912..3ba8dbdd 100644 --- a/test/plugins/test_completion.py +++ b/test/plugins/test_completion.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -from typing import Dict, NamedTuple +from typing import NamedTuple import pytest @@ -66,7 +66,7 @@ class TypeCase(NamedTuple): # fmt: off -TYPE_CASES: Dict[str, TypeCase] = { +TYPE_CASES: dict[str, TypeCase] = { "variable": TypeCase( document="test = 1\ntes", position={"line": 1, "character": 3}, diff --git a/test/plugins/test_flake8_lint.py b/test/plugins/test_flake8_lint.py index e7b6b001..d8199d63 100644 --- a/test/plugins/test_flake8_lint.py +++ b/test/plugins/test_flake8_lint.py @@ -125,20 +125,20 @@ def test_flake8_respecting_configuration(workspace) -> None: def test_flake8_config_param(workspace) -> None: with patch("pylsp.plugins.flake8_lint.Popen") as popen_mock: mock_instance = popen_mock.return_value - mock_instance.communicate.return_value = [bytes(), bytes()] + mock_instance.communicate.return_value = [b"", b""] flake8_conf = "/tmp/some.cfg" workspace._config.update({"plugins": {"flake8": {"config": flake8_conf}}}) _name, doc = temp_document(DOC, workspace) flake8_lint.pylsp_lint(workspace, doc) (call_args,) = popen_mock.call_args[0] assert "flake8" in call_args - assert "--config={}".format(flake8_conf) in call_args + assert f"--config={flake8_conf}" in call_args def test_flake8_executable_param(workspace) -> None: with patch("pylsp.plugins.flake8_lint.Popen") as popen_mock: mock_instance = popen_mock.return_value - mock_instance.communicate.return_value = [bytes(), bytes()] + mock_instance.communicate.return_value = [b"", b""] flake8_executable = "/tmp/flake8" workspace._config.update( @@ -187,7 +187,7 @@ def test_flake8_multiline(workspace) -> None: with patch("pylsp.plugins.flake8_lint.Popen") as popen_mock: mock_instance = popen_mock.return_value - mock_instance.communicate.return_value = [bytes(), bytes()] + mock_instance.communicate.return_value = [b"", b""] doc = workspace.get_document(doc_uri) flake8_lint.pylsp_lint(workspace, doc) diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index 9674b872..b507acd2 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -10,7 +10,7 @@ DOC_URI = uris.from_fs_path(__file__) DOC = """ -def main(): +def main(a: float, b: float): \"\"\"hello world\"\"\" pass """ @@ -79,13 +79,47 @@ def test_hover(workspace) -> None: doc = Document(DOC_URI, workspace, DOC) - contents = {"kind": "markdown", "value": "```python\nmain()\n```\n\n\nhello world"} + contents = { + "kind": "markdown", + "value": "```python\nmain(a: float, b: float)\n```\n\n\nhello world", + } assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) assert {"contents": ""} == pylsp_hover(doc._config, doc, no_hov_position) +def test_hover_signature_formatting(workspace) -> None: + # Over 'main' in def main(): + hov_position = {"line": 2, "character": 6} + + doc = Document(DOC_URI, workspace, DOC) + # setting low line length should trigger reflow to multiple lines + doc._config.update({"signature": {"line_length": 10}}) + + contents = { + "kind": "markdown", + "value": "```python\nmain(\n a: float,\n b: float,\n)\n```\n\n\nhello world", + } + + assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) + + +def test_hover_signature_formatting_opt_out(workspace) -> None: + # Over 'main' in def main(): + hov_position = {"line": 2, "character": 6} + + doc = Document(DOC_URI, workspace, DOC) + doc._config.update({"signature": {"line_length": 10, "formatter": None}}) + + contents = { + "kind": "markdown", + "value": "```python\nmain(a: float, b: float)\n```\n\n\nhello world", + } + + assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) + + def test_document_path_hover(workspace_other_root_path, tmpdir) -> None: # Create a dummy module out of the workspace's root_path and try to get # a definition on it in another file placed next to it. diff --git a/test/plugins/test_symbols.py b/test/plugins/test_symbols.py index c00ab935..242a38a1 100644 --- a/test/plugins/test_symbols.py +++ b/test/plugins/test_symbols.py @@ -30,6 +30,17 @@ def main(x): """ +DOC_IMPORTS = """from . import something +from ..module import something +from module import (a, b) + +def main(): + # import ignored + print("from module import x") # string with import + return something + +""" + def helper_check_symbols_all_scope(symbols): # All eight symbols (import sys, a, B, __init__, x, y, main, y) @@ -73,6 +84,24 @@ def sym(name): assert sym("main")["location"]["range"]["end"] == {"line": 12, "character": 0} +def test_symbols_complex_imports(config, workspace): + doc = Document(DOC_URI, workspace, DOC_IMPORTS) + config.update({"plugins": {"jedi_symbols": {"all_scopes": False}}}) + symbols = pylsp_document_symbols(config, doc) + + import_symbols = [s for s in symbols if s["kind"] == SymbolKind.Module] + + assert len(import_symbols) == 4 + + names = [s["name"] for s in import_symbols] + assert "something" in names + assert "a" in names or "b" in names + + assert any( + s["name"] == "main" and s["kind"] == SymbolKind.Function for s in symbols + ) + + def test_symbols_all_scopes(config, workspace) -> None: doc = Document(DOC_URI, workspace, DOC) symbols = pylsp_document_symbols(config, doc) diff --git a/test/plugins/test_type_definition.py b/test/plugins/test_type_definition.py new file mode 100644 index 00000000..b433fc63 --- /dev/null +++ b/test/plugins/test_type_definition.py @@ -0,0 +1,96 @@ +# Copyright 2021- Python Language Server Contributors. + +from pylsp import uris +from pylsp.plugins.type_definition import pylsp_type_definition +from pylsp.workspace import Document + +DOC_URI = uris.from_fs_path(__file__) +DOC = """\ +from dataclasses import dataclass + +@dataclass +class IntPair: + a: int + b: int + +def main() -> None: + l0 = list(1, 2) + + my_pair = IntPair(a=10, b=20) + print(f"Original pair: {my_pair}") +""" + + +def test_type_definitions(config, workspace) -> None: + # Over 'IntPair' in 'main' + cursor_pos = {"line": 10, "character": 14} + + # The definition of 'IntPair' + def_range = { + "start": {"line": 3, "character": 6}, + "end": {"line": 3, "character": 13}, + } + + doc = Document(DOC_URI, workspace, DOC) + assert [{"uri": DOC_URI, "range": def_range}] == pylsp_type_definition( + config, doc, cursor_pos + ) + + +def test_builtin_definition(config, workspace) -> None: + # Over 'list' in main + cursor_pos = {"line": 8, "character": 9} + + doc = Document(DOC_URI, workspace, DOC) + + defns = pylsp_type_definition(config, doc, cursor_pos) + assert len(defns) == 1 + assert defns[0]["uri"].endswith("builtins.pyi") + + +def test_mutli_file_type_definitions(config, workspace, tmpdir) -> None: + # Create a dummy module out of the workspace's root_path and try to get + # a definition on it in another file placed next to it. + module_content = """\ +from dataclasses import dataclass + +@dataclass +class IntPair: + a: int + b: int +""" + p1 = tmpdir.join("intpair.py") + p1.write(module_content) + # The uri for intpair.py + module_path = str(p1) + module_uri = uris.from_fs_path(module_path) + + # Content of doc to test type definition + doc_content = """\ +from intpair import IntPair + +def main() -> None: + l0 = list(1, 2) + + my_pair = IntPair(a=10, b=20) + print(f"Original pair: {my_pair}") +""" + p2 = tmpdir.join("main.py") + p2.write(doc_content) + doc_path = str(p2) + doc_uri = uris.from_fs_path(doc_path) + + doc = Document(doc_uri, workspace, doc_content) + + # The range where IntPair is defined in intpair.py + def_range = { + "start": {"line": 3, "character": 6}, + "end": {"line": 3, "character": 13}, + } + + # The position where IntPair is called in main.py + cursor_pos = {"line": 5, "character": 14} + + assert [{"uri": module_uri, "range": def_range}] == pylsp_type_definition( + config, doc, cursor_pos + ) diff --git a/test/test_python_lsp.py b/test/test_python_lsp.py new file mode 100644 index 00000000..b7b9daec --- /dev/null +++ b/test/test_python_lsp.py @@ -0,0 +1,161 @@ +import asyncio +import json +import os +import socket +import subprocess +import sys +import threading +import time + +import pytest +import websockets + +NUM_CLIENTS = 2 +NUM_REQUESTS = 5 +TEST_PORT = 5102 +HOST = "127.0.0.1" +MAX_STARTUP_SECONDS = 5.0 +CHECK_INTERVAL = 0.1 + + +@pytest.fixture(scope="module", autouse=True) +def ws_server_subprocess(): + cmd = [ + sys.executable, + "-m", + "pylsp.__main__", + "--ws", + "--host", + HOST, + "--port", + str(TEST_PORT), + ] + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=os.environ.copy(), + ) + + deadline = time.time() + MAX_STARTUP_SECONDS + while True: + try: + with socket.create_connection( + ("127.0.0.1", TEST_PORT), timeout=CHECK_INTERVAL + ): + break + except (ConnectionRefusedError, OSError): + if time.time() > deadline: + proc.kill() + out, err = proc.communicate(timeout=1) + raise RuntimeError( + f"Server didn’t start listening on port {TEST_PORT} in time.\n" + f"STDOUT:\n{out.decode()}\nSTDERR:\n{err.decode()}" + ) + time.sleep(CHECK_INTERVAL) + + yield # run the tests + + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + + +TEST_DOC = """\ +def test(): + '''Test documentation''' +test() +""" + + +def test_concurrent_ws_requests(): + errors = set() + lock = threading.Lock() + + def thread_target(i: int): + async def do_initialize(idx): + uri = f"ws://{HOST}:{TEST_PORT}" + async with websockets.connect(uri) as ws: + # send initialize + init_request = { + "jsonrpc": "2.0", + "id": 4 * idx, + "method": "initialize", + "params": {}, + } + did_open_request = { + "jsonrpc": "2.0", + "id": 4 * (idx + 1), + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": "test.py", + "languageId": "python", + "version": 0, + "text": TEST_DOC, + } + }, + } + + async def send_request(request: dict): + await asyncio.wait_for( + ws.send(json.dumps(request, ensure_ascii=False)), timeout=5 + ) + + async def get_json_reply(): + raw = await asyncio.wait_for(ws.recv(), timeout=60) + obj = json.loads(raw) + return obj + + try: + await send_request(init_request) + await get_json_reply() + await send_request(did_open_request) + await get_json_reply() + requests = [] + for i in range(NUM_REQUESTS): + hover_request = { + "jsonrpc": "2.0", + "id": 4 * (idx + 2 + i), + "method": "textDocument/definition", + "params": { + "textDocument": { + "uri": "test.py", + }, + "position": { + "line": 3, + "character": 2, + }, + }, + } + requests.append(send_request(hover_request)) + # send many requests in parallel + await asyncio.gather(*requests) + # collect replies + for i in range(NUM_REQUESTS): + hover = await get_json_reply() + assert hover + except (json.JSONDecodeError, asyncio.TimeoutError) as e: + return e + return None + + error = asyncio.run(do_initialize(i)) + with lock: + errors.add(error) + + # launch threads + threads = [] + for i in range(1, NUM_CLIENTS + 1): + t = threading.Thread(target=thread_target, args=(i,)) + t.start() + threads.append(t) + + # wait for them all + for t in threads: + t.join(timeout=50) + assert not t.is_alive(), f"Worker thread {t} hung!" + + assert not any(filter(bool, errors)) diff --git a/test/test_utils.py b/test/test_utils.py index 966c469e..7ed6214f 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -6,7 +6,7 @@ import sys import time from threading import Thread -from typing import Any, Dict, List +from typing import Any from unittest import mock from docstring_to_markdown import UnknownFormatError @@ -19,7 +19,7 @@ CALL_TIMEOUT_IN_SECONDS = 30 -def send_notebook_did_open(client, cells: List[str]) -> None: +def send_notebook_did_open(client, cells: list[str]) -> None: """ Sends a notebookDocument/didOpen notification with the given python cells. @@ -31,7 +31,7 @@ def send_notebook_did_open(client, cells: List[str]) -> None: ) -def notebook_with_python_cells(cells: List[str]): +def notebook_with_python_cells(cells: list[str]): """ Create a notebook document with the given python cells. @@ -61,7 +61,7 @@ def notebook_with_python_cells(cells: List[str]): } -def send_initialize_request(client, initialization_options: Dict[str, Any] = None): +def send_initialize_request(client, initialization_options: dict[str, Any] = None): return client._endpoint.request( "initialize", {