Skip to content

Allow to format signatures in docstrings #631

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
99 changes: 96 additions & 3 deletions pylsp/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import os
import pathlib
import re
import subprocess
import sys
import threading
import time
from typing import Optional
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions pylsp/config/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,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."
}
}
}
2 changes: 2 additions & 0 deletions pylsp/plugins/hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
}
27 changes: 22 additions & 5 deletions pylsp/plugins/jedi_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
]
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"]
Expand All @@ -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


Expand Down Expand Up @@ -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 = ""
Expand All @@ -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),
Expand All @@ -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":
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"pluggy>=1.0.0",
"python-lsp-jsonrpc>=1.1.0,<2.0.0",
"ujson>=3.0.0",
"black"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should ruff be added as an optional dependecy below?

Copy link
Member

@ccordoba12 ccordoba12 May 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so. The functionality to format signatures will be provided by Black and it'll be very similar to the one given by Ruff.

]
dynamic = ["version"]

Expand Down
38 changes: 36 additions & 2 deletions test/plugins/test_hover.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
DOC_URI = uris.from_fs_path(__file__)
DOC = """

def main():
def main(a: float, b: float):
\"\"\"hello world\"\"\"
pass
"""
Expand Down Expand Up @@ -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.
Expand Down