From 04cdb98781a9b0172f8d1b112e4c40606dfc3cb4 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 31 Mar 2025 14:21:58 +0100 Subject: [PATCH 01/13] Allow to format signatures in docstrings --- pylsp/_utils.py | 14 +++++++++++--- pylsp/hookspecs.py | 15 ++++++++++++--- pylsp/plugins/hover.py | 3 ++- pylsp/plugins/jedi_completion.py | 30 +++++++++++++++++++++++++----- pylsp/python_lsp.py | 26 +++++++++++++++++++++++--- 5 files changed, 73 insertions(+), 15 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index b96df5a9..e63a1acf 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -9,7 +9,7 @@ import re import threading import time -from typing import List, Optional +from typing import Callable, List, Optional import docstring_to_markdown import jedi @@ -210,7 +210,10 @@ def choose_markup_kind(client_supported_markup_kinds: List[str]): def format_docstring( - contents: str, markup_kind: str, signatures: Optional[List[str]] = None + contents: str, + markup_kind: str, + signatures: Optional[List[str]] = None, + signatures_to_markdown: Optional[Callable[[List[str]], str]] = None, ): """Transform the provided docstring into a MarkupContent object. @@ -232,7 +235,12 @@ def format_docstring( value = escape_markdown(contents) if signatures: - value = wrap_signature("\n".join(signatures)) + "\n\n" + value + if signatures_to_markdown is None: + wrapped_signatures = wrap_signature("\n".join(signatures)) + else: + wrapped_signatures = signatures_to_markdown(signatures) + + value = wrapped_signatures + "\n\n" + value return {"kind": "markdown", "value": value} value = contents diff --git a/pylsp/hookspecs.py b/pylsp/hookspecs.py index 41508be1..594efc5a 100644 --- a/pylsp/hookspecs.py +++ b/pylsp/hookspecs.py @@ -24,12 +24,16 @@ def pylsp_commands(config, workspace) -> None: @hookspec -def pylsp_completions(config, workspace, document, position, ignored_names) -> None: +def pylsp_completions( + config, workspace, document, position, ignored_names, signatures_to_markdown +) -> None: pass @hookspec(firstresult=True) -def pylsp_completion_item_resolve(config, workspace, document, completion_item) -> None: +def pylsp_completion_item_resolve( + config, workspace, document, completion_item, signatures_to_markdown +) -> None: pass @@ -89,7 +93,12 @@ def pylsp_format_range(config, workspace, document, range, options) -> None: @hookspec(firstresult=True) -def pylsp_hover(config, workspace, document, position) -> None: +def pylsp_hover(config, workspace, document, position, signatures_to_markdown) -> None: + pass + + +@hookspec(firstresult=True) +def pylsp_signatures_to_markdown(signatures) -> None: pass diff --git a/pylsp/plugins/hover.py b/pylsp/plugins/hover.py index ca69d1b3..285e8c8d 100644 --- a/pylsp/plugins/hover.py +++ b/pylsp/plugins/hover.py @@ -9,7 +9,7 @@ @hookimpl -def pylsp_hover(config, document, position): +def pylsp_hover(config, document, position, signatures_to_markdown=None): 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 +46,6 @@ def pylsp_hover(config, document, position): definition.docstring(raw=True), preferred_markup_kind, signatures=[signature] if signature else None, + signatures_to_markdown=signatures_to_markdown, ) } diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 2796a093..29f99e87 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -3,6 +3,7 @@ import logging import os +from typing import Callable, List, Optional import parso @@ -36,7 +37,7 @@ @hookimpl -def pylsp_completions(config, document, position): +def pylsp_completions(config, document, position, signatures_to_markdown=None): """Get formatted completions for current code position""" settings = config.plugin_settings("jedi_completion", document_path=document.path) resolve_eagerly = settings.get("eager", False) @@ -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, + signatures_to_markdown=signatures_to_markdown, ) 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, + signatures_to_markdown=signatures_to_markdown, ) 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, + signatures_to_markdown=signatures_to_markdown, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -137,7 +141,9 @@ 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, signatures_to_markdown=None +): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get( completion_item["label"] @@ -152,7 +158,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, + signatures_to_markdown=signatures_to_markdown, + ) return completion_item @@ -207,13 +218,19 @@ 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, + signatures_to_markdown: Optional[Callable[[List[str]], str]] = None, +): 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, + signatures_to_markdown=signatures_to_markdown, ) except Exception: docs = "" @@ -228,6 +245,7 @@ def _format_completion( resolve=False, resolve_label_or_snippet=False, snippet_support=False, + signatures_to_markdown=None, ): completion = { "label": _label(d, resolve_label_or_snippet), @@ -237,7 +255,9 @@ def _format_completion( } if resolve: - completion = _resolve_completion(completion, d, markup_kind) + completion = _resolve_completion( + completion, d, markup_kind, signatures_to_markdown + ) # Adjustments for file completions if d.type == "path": diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index ba41d6aa..875762bd 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -399,14 +399,21 @@ def completions(self, doc_uri, position): 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 + "pylsp_completions", + doc_uri, + position=position, + ignored_names=ignored_names, + signatures_to_markdown=self._signatures_to_markdown, ) return {"isIncomplete": False, "items": flatten(completions)} def completion_item_resolve(self, completion_item): doc_uri = completion_item.get("data", {}).get("doc_uri", None) return self._hook( - "pylsp_completion_item_resolve", doc_uri, completion_item=completion_item + "pylsp_completion_item_resolve", + doc_uri, + completion_item=completion_item, + signatures_to_markdown=self._signatures_to_markdown, ) def definitions(self, doc_uri, position): @@ -434,7 +441,12 @@ def highlight(self, doc_uri, position): ) def hover(self, doc_uri, position): - return self._hook("pylsp_hover", doc_uri, position=position) or {"contents": ""} + return self._hook( + "pylsp_hover", + doc_uri, + position=position, + signatures_to_markdown=self._signatures_to_markdown, + ) or {"contents": ""} @_utils.debounce(LINT_DEBOUNCE_S, keyed_by="doc_uri") def lint(self, doc_uri, is_saved) -> None: @@ -888,6 +900,14 @@ def m_workspace__did_change_watched_files(self, changes=None, **_kwargs): def m_workspace__execute_command(self, command=None, arguments=None): return self.execute_command(command, arguments) + @property + def _signatures_to_markdown(self): + if not hasattr(self, "_signatures_to_markdown_hook"): + self._signatures_to_markdown_hook = self._hook( + "pylsp_signatures_to_markdown" + ) + return self._signatures_to_markdown_hook + def flatten(list_of_lists): return [item for lst in list_of_lists for item in lst] From 0a3113ec38a4be85abcfac7e23a1c0ed09ae157c Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:19:55 +0100 Subject: [PATCH 02/13] Add a test --- test/plugins/test_hover.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index 9674b872..2bdaa516 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,36 @@ 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_custom_signature(workspace) -> None: + # Over 'main' in def main(): + hov_position = {"line": 2, "character": 6} + + doc = Document(DOC_URI, workspace, DOC) + + contents = { + "kind": "markdown", + "value": "```python\nmain(\n a: float,\n b: float\n)\n```\n\n\nhello world", + } + + def signatures_to_markdown(signatures: list): + # dummy implementation for tests + return "```python\nmain(\n a: float,\n b: float\n)\n```\n" + + assert {"contents": contents} == pylsp_hover( + doc._config, doc, hov_position, signatures_to_markdown=signatures_to_markdown + ) + + 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. From d469b6d53b09adf369e27cb96eae380af26e19a7 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Fri, 9 May 2025 21:18:00 +0100 Subject: [PATCH 03/13] Simplify hook usage, do not use default args as these are not supported by pluggy --- pylsp/_utils.py | 14 +++++++++----- pylsp/plugins/hover.py | 2 +- pylsp/plugins/jedi_completion.py | 4 ++-- pylsp/python_lsp.py | 11 ++++------- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index e63a1acf..5e448f89 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -209,6 +209,10 @@ def choose_markup_kind(client_supported_markup_kinds: List[str]): return "markdown" +def convert_signatures_to_markdown(signatures: List[str]) -> str: + return wrap_signature("\n".join(signatures)) + + def format_docstring( contents: str, markup_kind: str, @@ -235,11 +239,11 @@ def format_docstring( value = escape_markdown(contents) if signatures: - if signatures_to_markdown is None: - wrapped_signatures = wrap_signature("\n".join(signatures)) - else: - wrapped_signatures = signatures_to_markdown(signatures) - + wrapped_signatures = ( + signatures_to_markdown(signatures) + if signatures_to_markdown + else convert_signatures_to_markdown(signatures) + ) value = wrapped_signatures + "\n\n" + value return {"kind": "markdown", "value": value} diff --git a/pylsp/plugins/hover.py b/pylsp/plugins/hover.py index 285e8c8d..aae1ad7a 100644 --- a/pylsp/plugins/hover.py +++ b/pylsp/plugins/hover.py @@ -9,7 +9,7 @@ @hookimpl -def pylsp_hover(config, document, position, signatures_to_markdown=None): +def pylsp_hover(config, document, position, signatures_to_markdown): 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) diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 29f99e87..909ed0b8 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -37,7 +37,7 @@ @hookimpl -def pylsp_completions(config, document, position, signatures_to_markdown=None): +def pylsp_completions(config, document, position, signatures_to_markdown): """Get formatted completions for current code position""" settings = config.plugin_settings("jedi_completion", document_path=document.path) resolve_eagerly = settings.get("eager", False) @@ -142,7 +142,7 @@ def pylsp_completions(config, document, position, signatures_to_markdown=None): @hookimpl def pylsp_completion_item_resolve( - config, completion_item, document, signatures_to_markdown=None + config, completion_item, document, signatures_to_markdown ): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get( diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index 875762bd..2718c7b4 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -900,13 +900,10 @@ def m_workspace__did_change_watched_files(self, changes=None, **_kwargs): def m_workspace__execute_command(self, command=None, arguments=None): return self.execute_command(command, arguments) - @property - def _signatures_to_markdown(self): - if not hasattr(self, "_signatures_to_markdown_hook"): - self._signatures_to_markdown_hook = self._hook( - "pylsp_signatures_to_markdown" - ) - return self._signatures_to_markdown_hook + def _signatures_to_markdown(self, signatures): + return self._hook( + "pylsp_signatures_to_markdown", signatures=signatures + ) or _utils.convert_signatures_to_markdown(signatures=signatures) def flatten(list_of_lists): From abace2cb4436159958a53f126d0f6620eef519e6 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Fri, 9 May 2025 21:27:12 +0100 Subject: [PATCH 04/13] Fix tests --- test/plugins/test_completion.py | 8 +++++++- test/plugins/test_hover.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py index b8de8912..aa5fb494 100644 --- a/test/plugins/test_completion.py +++ b/test/plugins/test_completion.py @@ -4,6 +4,7 @@ import math import os import sys +from functools import partial from pathlib import Path from typing import Dict, NamedTuple @@ -18,6 +19,8 @@ from pylsp.plugins.rope_completion import pylsp_completions as pylsp_rope_completions from pylsp.workspace import Document +pylsp_jedi_completions = partial(pylsp_jedi_completions, signatures_to_markdown=None) + PY2 = sys.version[0] == "2" LINUX = sys.platform.startswith("linux") CI = os.environ.get("CI") @@ -162,7 +165,10 @@ def test_jedi_completion_item_resolve(config, workspace) -> None: assert "detail" not in documented_hello_item resolved_documented_hello = pylsp_jedi_completion_item_resolve( - doc._config, completion_item=documented_hello_item, document=doc + doc._config, + completion_item=documented_hello_item, + document=doc, + signatures_to_markdown=None, ) expected_doc = { "kind": "markdown", diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index 2bdaa516..e76308ab 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -2,6 +2,7 @@ # Copyright 2021- Python Language Server Contributors. import os +from functools import partial from pylsp import uris from pylsp.plugins.hover import pylsp_hover @@ -22,6 +23,8 @@ def main(a: float, b: float): """ +pylsp_hover = partial(pylsp_hover, signatures_to_markdown=None) + def test_numpy_hover(workspace) -> None: # Over the blank line From 8758d56911f5c1727c6d7f2367936a4f56e01f87 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Fri, 9 May 2025 21:30:01 +0100 Subject: [PATCH 05/13] One more test --- test/plugins/test_completion.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py index aa5fb494..76556885 100644 --- a/test/plugins/test_completion.py +++ b/test/plugins/test_completion.py @@ -541,7 +541,9 @@ def test_jedi_completion_environment(workspace) -> None: completions = pylsp_jedi_completions(doc._config, doc, com_position) assert completions[0]["label"] == "loghub" - resolved = pylsp_jedi_completion_item_resolve(doc._config, completions[0], doc) + resolved = pylsp_jedi_completion_item_resolve( + doc._config, completions[0], doc, signatures_to_markdown=None + ) assert "changelog generator" in resolved["documentation"]["value"].lower() From d5c1e8e581139ecf33c99e7f76ad3a5f831e9eda Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 24 May 2025 18:05:30 +0100 Subject: [PATCH 06/13] Remove the hook, implement formatters natively --- pylsp/_utils.py | 90 +++++++++++++++++++++++++++++--- pylsp/config/schema.json | 22 +++++++- pylsp/hookspecs.py | 15 ++---- pylsp/plugins/hover.py | 5 +- pylsp/plugins/jedi_completion.py | 30 +++++------ pylsp/python_lsp.py | 23 ++------ test/plugins/test_completion.py | 11 +--- test/plugins/test_hover.py | 8 +-- 8 files changed, 130 insertions(+), 74 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index 5e448f89..9f66cc0f 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -57,7 +57,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) @@ -209,7 +209,85 @@ def choose_markup_kind(client_supported_markup_kinds: List[str]): return "markdown" -def convert_signatures_to_markdown(signatures: List[str]) -> str: +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 CalledProcessError: + return False + + +class RuffFormatter: + command = ["ruff", "format"] + + +class BlackFormatter: + command = ["black"] + + +formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()} + + +def format_signature(signature: str, 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("format", "black") + if signature_formatter: + signatures = [ + format_signature(signature, config=config) for signature in signatures + ] return wrap_signature("\n".join(signatures)) @@ -217,7 +295,7 @@ def format_docstring( contents: str, markup_kind: str, signatures: Optional[List[str]] = None, - signatures_to_markdown: Optional[Callable[[List[str]], str]] = None, + signature_config: Optional[dict] = None, ): """Transform the provided docstring into a MarkupContent object. @@ -239,10 +317,8 @@ def format_docstring( value = escape_markdown(contents) if signatures: - wrapped_signatures = ( - signatures_to_markdown(signatures) - if signatures_to_markdown - else convert_signatures_to_markdown(signatures) + wrapped_signatures = convert_signatures_to_markdown( + signatures, config=signature_config or {} ) value = wrapped_signatures + "\n\n" + value diff --git a/pylsp/config/schema.json b/pylsp/config/schema.json index 18248384..d1c3d2db 100644 --- a/pylsp/config/schema.json +++ b/pylsp/config/schema.json @@ -511,6 +511,26 @@ }, "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 594efc5a..41508be1 100644 --- a/pylsp/hookspecs.py +++ b/pylsp/hookspecs.py @@ -24,16 +24,12 @@ def pylsp_commands(config, workspace) -> None: @hookspec -def pylsp_completions( - config, workspace, document, position, ignored_names, signatures_to_markdown -) -> None: +def pylsp_completions(config, workspace, document, position, ignored_names) -> None: pass @hookspec(firstresult=True) -def pylsp_completion_item_resolve( - config, workspace, document, completion_item, signatures_to_markdown -) -> None: +def pylsp_completion_item_resolve(config, workspace, document, completion_item) -> None: pass @@ -93,12 +89,7 @@ def pylsp_format_range(config, workspace, document, range, options) -> None: @hookspec(firstresult=True) -def pylsp_hover(config, workspace, document, position, signatures_to_markdown) -> None: - pass - - -@hookspec(firstresult=True) -def pylsp_signatures_to_markdown(signatures) -> None: +def pylsp_hover(config, workspace, document, position) -> None: pass diff --git a/pylsp/plugins/hover.py b/pylsp/plugins/hover.py index aae1ad7a..ef392687 100644 --- a/pylsp/plugins/hover.py +++ b/pylsp/plugins/hover.py @@ -9,7 +9,8 @@ @hookimpl -def pylsp_hover(config, document, position, signatures_to_markdown): +def pylsp_hover(config, document, position): + signature_config = config.settings.get("signatures", {}) 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,6 +47,6 @@ def pylsp_hover(config, document, position, signatures_to_markdown): definition.docstring(raw=True), preferred_markup_kind, signatures=[signature] if signature else None, - signatures_to_markdown=signatures_to_markdown, + signature_config=signature_config, ) } diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 909ed0b8..46fd2b1b 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -37,12 +37,13 @@ @hookimpl -def pylsp_completions(config, document, position, signatures_to_markdown): +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("signatures", {}) + 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) @@ -89,7 +90,7 @@ def pylsp_completions(config, document, position, signatures_to_markdown): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, - signatures_to_markdown=signatures_to_markdown, + signature_config=signature_config, ) for i, c in enumerate(completions) ] @@ -105,7 +106,7 @@ def pylsp_completions(config, document, position, signatures_to_markdown): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, - signatures_to_markdown=signatures_to_markdown, + signature_config=signature_config, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -121,7 +122,7 @@ def pylsp_completions(config, document, position, signatures_to_markdown): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, - signatures_to_markdown=signatures_to_markdown, + signature_config=signature_config, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -142,7 +143,9 @@ def pylsp_completions(config, document, position, signatures_to_markdown): @hookimpl def pylsp_completion_item_resolve( - config, completion_item, document, signatures_to_markdown + config, + completion_item, + document, ): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get( @@ -162,7 +165,7 @@ def pylsp_completion_item_resolve( completion, data, markup_kind=preferred_markup_kind, - signatures_to_markdown=signatures_to_markdown, + signature_config=config.settings.get("signatures", {}), ) return completion_item @@ -218,19 +221,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, - signatures_to_markdown: Optional[Callable[[List[str]], str]] = None, -): +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, - signatures_to_markdown=signatures_to_markdown, + signature_config=signature_config, ) except Exception: docs = "" @@ -245,7 +243,7 @@ def _format_completion( resolve=False, resolve_label_or_snippet=False, snippet_support=False, - signatures_to_markdown=None, + signature_config=None, ): completion = { "label": _label(d, resolve_label_or_snippet), @@ -256,7 +254,7 @@ def _format_completion( if resolve: completion = _resolve_completion( - completion, d, markup_kind, signatures_to_markdown + completion, d, markup_kind, signature_config=signature_config ) # Adjustments for file completions diff --git a/pylsp/python_lsp.py b/pylsp/python_lsp.py index 2718c7b4..ba41d6aa 100644 --- a/pylsp/python_lsp.py +++ b/pylsp/python_lsp.py @@ -399,21 +399,14 @@ def completions(self, doc_uri, position): 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, - signatures_to_markdown=self._signatures_to_markdown, + "pylsp_completions", doc_uri, position=position, ignored_names=ignored_names ) return {"isIncomplete": False, "items": flatten(completions)} def completion_item_resolve(self, completion_item): doc_uri = completion_item.get("data", {}).get("doc_uri", None) return self._hook( - "pylsp_completion_item_resolve", - doc_uri, - completion_item=completion_item, - signatures_to_markdown=self._signatures_to_markdown, + "pylsp_completion_item_resolve", doc_uri, completion_item=completion_item ) def definitions(self, doc_uri, position): @@ -441,12 +434,7 @@ def highlight(self, doc_uri, position): ) def hover(self, doc_uri, position): - return self._hook( - "pylsp_hover", - doc_uri, - position=position, - signatures_to_markdown=self._signatures_to_markdown, - ) or {"contents": ""} + return self._hook("pylsp_hover", doc_uri, position=position) or {"contents": ""} @_utils.debounce(LINT_DEBOUNCE_S, keyed_by="doc_uri") def lint(self, doc_uri, is_saved) -> None: @@ -900,11 +888,6 @@ def m_workspace__did_change_watched_files(self, changes=None, **_kwargs): def m_workspace__execute_command(self, command=None, arguments=None): return self.execute_command(command, arguments) - def _signatures_to_markdown(self, signatures): - return self._hook( - "pylsp_signatures_to_markdown", signatures=signatures - ) or _utils.convert_signatures_to_markdown(signatures=signatures) - def flatten(list_of_lists): return [item for lst in list_of_lists for item in lst] diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py index 76556885..314dd0d8 100644 --- a/test/plugins/test_completion.py +++ b/test/plugins/test_completion.py @@ -19,8 +19,6 @@ from pylsp.plugins.rope_completion import pylsp_completions as pylsp_rope_completions from pylsp.workspace import Document -pylsp_jedi_completions = partial(pylsp_jedi_completions, signatures_to_markdown=None) - PY2 = sys.version[0] == "2" LINUX = sys.platform.startswith("linux") CI = os.environ.get("CI") @@ -165,10 +163,7 @@ def test_jedi_completion_item_resolve(config, workspace) -> None: assert "detail" not in documented_hello_item resolved_documented_hello = pylsp_jedi_completion_item_resolve( - doc._config, - completion_item=documented_hello_item, - document=doc, - signatures_to_markdown=None, + doc._config, completion_item=documented_hello_item, document=doc ) expected_doc = { "kind": "markdown", @@ -541,9 +536,7 @@ def test_jedi_completion_environment(workspace) -> None: completions = pylsp_jedi_completions(doc._config, doc, com_position) assert completions[0]["label"] == "loghub" - resolved = pylsp_jedi_completion_item_resolve( - doc._config, completions[0], doc, signatures_to_markdown=None - ) + resolved = pylsp_jedi_completion_item_resolve(doc._config, completions[0], doc) assert "changelog generator" in resolved["documentation"]["value"].lower() diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index e76308ab..2fab275d 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -103,13 +103,7 @@ def test_hover_custom_signature(workspace) -> None: "value": "```python\nmain(\n a: float,\n b: float\n)\n```\n\n\nhello world", } - def signatures_to_markdown(signatures: list): - # dummy implementation for tests - return "```python\nmain(\n a: float,\n b: float\n)\n```\n" - - assert {"contents": contents} == pylsp_hover( - doc._config, doc, hov_position, signatures_to_markdown=signatures_to_markdown - ) + assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) def test_document_path_hover(workspace_other_root_path, tmpdir) -> None: From da046722b3463ca465e972b90b3eda2e55614ea2 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 24 May 2025 18:05:57 +0100 Subject: [PATCH 07/13] Add black as a dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f9c6a521..4020baba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] From 573eac0dddf37961d61682afd52ff9a16cf5fd49 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 24 May 2025 18:17:41 +0100 Subject: [PATCH 08/13] Fixes --- pylsp/_utils.py | 15 ++++++++++----- pylsp/plugins/hover.py | 2 +- pylsp/plugins/jedi_completion.py | 4 ++-- test/plugins/test_hover.py | 8 ++++---- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index 9f66cc0f..d79fd9cd 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -7,6 +7,8 @@ import os import pathlib import re +import subprocess +import sys import threading import time from typing import Callable, List, Optional @@ -245,22 +247,22 @@ def _is_available_via_cli(self) -> bool: ], ) return True - except CalledProcessError: + except subprocess.CalledProcessError: return False -class RuffFormatter: +class RuffFormatter(Formatter): command = ["ruff", "format"] -class BlackFormatter: +class BlackFormatter(Formatter): command = ["black"] formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()} -def format_signature(signature: str, signature_formatter: str) -> str: +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) @@ -286,7 +288,10 @@ def convert_signatures_to_markdown(signatures: List[str], config: dict) -> str: signature_formatter = config.get("format", "black") if signature_formatter: signatures = [ - format_signature(signature, config=config) for signature in signatures + format_signature( + signature, signature_formatter=signature_formatter, config=config + ) + for signature in signatures ] return wrap_signature("\n".join(signatures)) diff --git a/pylsp/plugins/hover.py b/pylsp/plugins/hover.py index ef392687..daaae90b 100644 --- a/pylsp/plugins/hover.py +++ b/pylsp/plugins/hover.py @@ -10,7 +10,7 @@ @hookimpl def pylsp_hover(config, document, position): - signature_config = config.settings.get("signatures", {}) + 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) diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 46fd2b1b..a8802a63 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -41,7 +41,7 @@ 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) - signature_config = config.settings.get("signatures", {}) + signature_config = config.settings().get("signature", {}) code_position = _utils.position_to_jedi_linecolumn(document, position) code_position["fuzzy"] = settings.get("fuzzy", False) @@ -165,7 +165,7 @@ def pylsp_completion_item_resolve( completion, data, markup_kind=preferred_markup_kind, - signature_config=config.settings.get("signatures", {}), + signature_config=config.settings().get("signature", {}), ) return completion_item diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index 2fab275d..5abaab74 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -23,8 +23,6 @@ def main(a: float, b: float): """ -pylsp_hover = partial(pylsp_hover, signatures_to_markdown=None) - def test_numpy_hover(workspace) -> None: # Over the blank line @@ -92,15 +90,17 @@ def test_hover(workspace) -> None: assert {"contents": ""} == pylsp_hover(doc._config, doc, no_hov_position) -def test_hover_custom_signature(workspace) -> None: +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", + "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) From 011882d04457dfdf24cd8dac123e9e4ce2843bff Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 24 May 2025 18:19:38 +0100 Subject: [PATCH 09/13] Fix/update config --- CONFIGURATION.md | 2 ++ pylsp/config/schema.json | 8 +++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0609169b..93b828b0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -75,5 +75,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/pylsp/config/schema.json b/pylsp/config/schema.json index d1c3d2db..c4aec460 100644 --- a/pylsp/config/schema.json +++ b/pylsp/config/schema.json @@ -521,16 +521,14 @@ "black", "ruff", null - ] + ], "default": "black", "description": "Formatter to use for reformatting signatures in docstrings." }, "pylsp.signature.line_length": { - "type": [ - "number", - ], + "type": "number", "default": 88, "description": "Maximum line length in signatures." - }, + } } } From 6a26eeb378e7302363f41fbfb2bb3bf4a0c060fd Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 24 May 2025 18:23:18 +0100 Subject: [PATCH 10/13] Fix opt-out, add a test --- pylsp/_utils.py | 2 +- test/plugins/test_hover.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index d79fd9cd..3edec166 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -285,7 +285,7 @@ def format_signature(signature: str, config: dict, signature_formatter: str) -> def convert_signatures_to_markdown(signatures: List[str], config: dict) -> str: - signature_formatter = config.get("format", "black") + signature_formatter = config.get("formatter", "black") if signature_formatter: signatures = [ format_signature( diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index 5abaab74..e8cc8fd8 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -106,6 +106,20 @@ def test_hover_signature_formatting(workspace) -> None: 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. From 728d61c00ec337067e6fde4f7a12304013d54aa0 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sat, 24 May 2025 18:27:33 +0100 Subject: [PATCH 11/13] Lint --- pylsp/_utils.py | 2 +- pylsp/plugins/jedi_completion.py | 1 - test/plugins/test_completion.py | 1 - test/plugins/test_hover.py | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index 3edec166..7dfd4988 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -11,7 +11,7 @@ import sys import threading import time -from typing import Callable, List, Optional +from typing import List, Optional import docstring_to_markdown import jedi diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index a8802a63..51c3589c 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -3,7 +3,6 @@ import logging import os -from typing import Callable, List, Optional import parso diff --git a/test/plugins/test_completion.py b/test/plugins/test_completion.py index 314dd0d8..b8de8912 100644 --- a/test/plugins/test_completion.py +++ b/test/plugins/test_completion.py @@ -4,7 +4,6 @@ import math import os import sys -from functools import partial from pathlib import Path from typing import Dict, NamedTuple diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index e8cc8fd8..b507acd2 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -2,7 +2,6 @@ # Copyright 2021- Python Language Server Contributors. import os -from functools import partial from pylsp import uris from pylsp.plugins.hover import pylsp_hover @@ -120,6 +119,7 @@ def test_hover_signature_formatting_opt_out(workspace) -> None: 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. From 9f0ef6f928995fb108e81443c4793432cd609606 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Sun, 25 May 2025 11:33:24 +0100 Subject: [PATCH 12/13] Python 3.8 support --- pylsp/_utils.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index 7dfd4988..a0c7eb1e 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -262,6 +262,18 @@ class BlackFormatter(Formatter): formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()} +def removeprefix(text: str, prefix: str) -> str: + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +def removesuffix(text: str, suffix: str) -> str: + if suffix and text.endswith(suffix): + return text[: -len(suffix)] + return text + + 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" @@ -270,9 +282,14 @@ def format_signature(signature: str, config: dict, signature_formatter: str) -> if formatter.is_installed: try: return ( - formatter.format(as_func, line_length=line_length) - .removeprefix("def ") - .removesuffix(":\n pass") + # TODO: replace with str.removeprefix and str.removesuffix + # once Python 3.8 support is no longer required + removesuffix( + removeprefix( + formatter.format(as_func, line_length=line_length), "def " + ), + ":\n pass", + ) ) except subprocess.CalledProcessError as e: log.warning("Signature formatter failed %s", e) From 875d8ca669a517dd2f45f858518d230394fa4f37 Mon Sep 17 00:00:00 2001 From: krassowski <5832902+krassowski@users.noreply.github.com> Date: Wed, 28 May 2025 17:41:25 +0100 Subject: [PATCH 13/13] Remove code for Python 3.8 compatibility --- pylsp/_utils.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/pylsp/_utils.py b/pylsp/_utils.py index d9bd6358..dfe84b14 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -262,18 +262,6 @@ class BlackFormatter(Formatter): formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()} -def removeprefix(text: str, prefix: str) -> str: - if text.startswith(prefix): - return text[len(prefix) :] - return text - - -def removesuffix(text: str, suffix: str) -> str: - if suffix and text.endswith(suffix): - return text[: -len(suffix)] - return text - - 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" @@ -282,14 +270,9 @@ def format_signature(signature: str, config: dict, signature_formatter: str) -> if formatter.is_installed: try: return ( - # TODO: replace with str.removeprefix and str.removesuffix - # once Python 3.8 support is no longer required - removesuffix( - removeprefix( - formatter.format(as_func, line_length=line_length), "def " - ), - ":\n pass", - ) + 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)