Skip to content

Commit c8e4e99

Browse files
authored
Allow to format signatures in docstrings (#631)
1 parent e9776fd commit c8e4e99

File tree

7 files changed

+177
-10
lines changed

7 files changed

+177
-10
lines changed

CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,7 @@ This server can be configured using the `workspace/didChangeConfiguration` metho
7575
| `pylsp.plugins.yapf.enabled` | `boolean` | Enable or disable the plugin. | `true` |
7676
| `pylsp.rope.extensionModules` | `string` | Builtin and c-extension modules that are allowed to be imported and inspected by rope. | `null` |
7777
| `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` |
78+
| `pylsp.signature.formatter` | `string` (one of: `'black'`, `'ruff'`, `None`) | Formatter to use for reformatting signatures in docstrings. | `"black"` |
79+
| `pylsp.signature.line_length` | `number` | Maximum line length in signatures. | `88` |
7880

7981
This documentation was generated from `pylsp/config/schema.json`. Please do not edit this file directly.

pylsp/_utils.py

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import os
88
import pathlib
99
import re
10+
import subprocess
11+
import sys
1012
import threading
1113
import time
1214
from typing import Optional
@@ -57,7 +59,7 @@ def run():
5759

5860

5961
def throttle(seconds=1):
60-
"""Throttles calls to a function evey `seconds` seconds."""
62+
"""Throttles calls to a function every `seconds` seconds."""
6163

6264
def decorator(func):
6365
@functools.wraps(func)
@@ -209,8 +211,96 @@ def choose_markup_kind(client_supported_markup_kinds: list[str]):
209211
return "markdown"
210212

211213

214+
class Formatter:
215+
command: list[str]
216+
217+
@property
218+
def is_installed(self) -> bool:
219+
"""Returns whether formatter is available"""
220+
if not hasattr(self, "_is_installed"):
221+
self._is_installed = self._is_available_via_cli()
222+
return self._is_installed
223+
224+
def format(self, code: str, line_length: int) -> str:
225+
"""Formats code"""
226+
return subprocess.check_output(
227+
[
228+
sys.executable,
229+
"-m",
230+
*self.command,
231+
"--line-length",
232+
str(line_length),
233+
"-",
234+
],
235+
input=code,
236+
text=True,
237+
).strip()
238+
239+
def _is_available_via_cli(self) -> bool:
240+
try:
241+
subprocess.check_output(
242+
[
243+
sys.executable,
244+
"-m",
245+
*self.command,
246+
"--help",
247+
],
248+
)
249+
return True
250+
except subprocess.CalledProcessError:
251+
return False
252+
253+
254+
class RuffFormatter(Formatter):
255+
command = ["ruff", "format"]
256+
257+
258+
class BlackFormatter(Formatter):
259+
command = ["black"]
260+
261+
262+
formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()}
263+
264+
265+
def format_signature(signature: str, config: dict, signature_formatter: str) -> str:
266+
"""Formats signature using ruff or black if either is available."""
267+
as_func = f"def {signature.strip()}:\n pass"
268+
line_length = config.get("line_length", 88)
269+
formatter = formatters[signature_formatter]
270+
if formatter.is_installed:
271+
try:
272+
return (
273+
formatter.format(as_func, line_length=line_length)
274+
.removeprefix("def ")
275+
.removesuffix(":\n pass")
276+
)
277+
except subprocess.CalledProcessError as e:
278+
log.warning("Signature formatter failed %s", e)
279+
else:
280+
log.warning(
281+
"Formatter %s was requested but it does not appear to be installed",
282+
signature_formatter,
283+
)
284+
return signature
285+
286+
287+
def convert_signatures_to_markdown(signatures: list[str], config: dict) -> str:
288+
signature_formatter = config.get("formatter", "black")
289+
if signature_formatter:
290+
signatures = [
291+
format_signature(
292+
signature, signature_formatter=signature_formatter, config=config
293+
)
294+
for signature in signatures
295+
]
296+
return wrap_signature("\n".join(signatures))
297+
298+
212299
def format_docstring(
213-
contents: str, markup_kind: str, signatures: Optional[list[str]] = None
300+
contents: str,
301+
markup_kind: str,
302+
signatures: Optional[list[str]] = None,
303+
signature_config: Optional[dict] = None,
214304
):
215305
"""Transform the provided docstring into a MarkupContent object.
216306
@@ -232,7 +322,10 @@ def format_docstring(
232322
value = escape_markdown(contents)
233323

234324
if signatures:
235-
value = wrap_signature("\n".join(signatures)) + "\n\n" + value
325+
wrapped_signatures = convert_signatures_to_markdown(
326+
signatures, config=signature_config or {}
327+
)
328+
value = wrapped_signatures + "\n\n" + value
236329

237330
return {"kind": "markdown", "value": value}
238331
value = contents

pylsp/config/schema.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,24 @@
511511
},
512512
"uniqueItems": true,
513513
"description": "The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all."
514+
},
515+
"pylsp.signature.formatter": {
516+
"type": [
517+
"string",
518+
"null"
519+
],
520+
"enum": [
521+
"black",
522+
"ruff",
523+
null
524+
],
525+
"default": "black",
526+
"description": "Formatter to use for reformatting signatures in docstrings."
527+
},
528+
"pylsp.signature.line_length": {
529+
"type": "number",
530+
"default": 88,
531+
"description": "Maximum line length in signatures."
514532
}
515533
}
516534
}

pylsp/plugins/hover.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
@hookimpl
1212
def pylsp_hover(config, document, position):
13+
signature_config = config.settings().get("signature", {})
1314
code_position = _utils.position_to_jedi_linecolumn(document, position)
1415
definitions = document.jedi_script(use_document_path=True).infer(**code_position)
1516
word = document.word_at_position(position)
@@ -46,5 +47,6 @@ def pylsp_hover(config, document, position):
4647
definition.docstring(raw=True),
4748
preferred_markup_kind,
4849
signatures=[signature] if signature else None,
50+
signature_config=signature_config,
4951
)
5052
}

pylsp/plugins/jedi_completion.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ def pylsp_completions(config, document, position):
4040
"""Get formatted completions for current code position"""
4141
settings = config.plugin_settings("jedi_completion", document_path=document.path)
4242
resolve_eagerly = settings.get("eager", False)
43-
code_position = _utils.position_to_jedi_linecolumn(document, position)
43+
signature_config = config.settings().get("signature", {})
4444

45+
code_position = _utils.position_to_jedi_linecolumn(document, position)
4546
code_position["fuzzy"] = settings.get("fuzzy", False)
4647
completions = document.jedi_script(use_document_path=True).complete(**code_position)
4748

@@ -88,6 +89,7 @@ def pylsp_completions(config, document, position):
8889
resolve=resolve_eagerly,
8990
resolve_label_or_snippet=(i < max_to_resolve),
9091
snippet_support=snippet_support,
92+
signature_config=signature_config,
9193
)
9294
for i, c in enumerate(completions)
9395
]
@@ -103,6 +105,7 @@ def pylsp_completions(config, document, position):
103105
resolve=resolve_eagerly,
104106
resolve_label_or_snippet=(i < max_to_resolve),
105107
snippet_support=snippet_support,
108+
signature_config=signature_config,
106109
)
107110
completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter
108111
completion_dict["label"] += " object"
@@ -118,6 +121,7 @@ def pylsp_completions(config, document, position):
118121
resolve=resolve_eagerly,
119122
resolve_label_or_snippet=(i < max_to_resolve),
120123
snippet_support=snippet_support,
124+
signature_config=signature_config,
121125
)
122126
completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter
123127
completion_dict["label"] += " object"
@@ -137,7 +141,11 @@ def pylsp_completions(config, document, position):
137141

138142

139143
@hookimpl
140-
def pylsp_completion_item_resolve(config, completion_item, document):
144+
def pylsp_completion_item_resolve(
145+
config,
146+
completion_item,
147+
document,
148+
):
141149
"""Resolve formatted completion for given non-resolved completion"""
142150
shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get(
143151
completion_item["label"]
@@ -152,7 +160,12 @@ def pylsp_completion_item_resolve(config, completion_item, document):
152160

153161
if shared_data:
154162
completion, data = shared_data
155-
return _resolve_completion(completion, data, markup_kind=preferred_markup_kind)
163+
return _resolve_completion(
164+
completion,
165+
data,
166+
markup_kind=preferred_markup_kind,
167+
signature_config=config.settings().get("signature", {}),
168+
)
156169
return completion_item
157170

158171

@@ -207,13 +220,14 @@ def use_snippets(document, position):
207220
return expr_type not in _IMPORTS and not (expr_type in _ERRORS and "import" in code)
208221

209222

210-
def _resolve_completion(completion, d, markup_kind: str):
223+
def _resolve_completion(completion, d, markup_kind: str, signature_config: dict):
211224
completion["detail"] = _detail(d)
212225
try:
213226
docs = _utils.format_docstring(
214227
d.docstring(raw=True),
215228
signatures=[signature.to_string() for signature in d.get_signatures()],
216229
markup_kind=markup_kind,
230+
signature_config=signature_config,
217231
)
218232
except Exception:
219233
docs = ""
@@ -228,6 +242,7 @@ def _format_completion(
228242
resolve=False,
229243
resolve_label_or_snippet=False,
230244
snippet_support=False,
245+
signature_config=None,
231246
):
232247
completion = {
233248
"label": _label(d, resolve_label_or_snippet),
@@ -237,7 +252,9 @@ def _format_completion(
237252
}
238253

239254
if resolve:
240-
completion = _resolve_completion(completion, d, markup_kind)
255+
completion = _resolve_completion(
256+
completion, d, markup_kind, signature_config=signature_config
257+
)
241258

242259
# Adjustments for file completions
243260
if d.type == "path":

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"pluggy>=1.0.0",
2020
"python-lsp-jsonrpc>=1.1.0,<2.0.0",
2121
"ujson>=3.0.0",
22+
"black"
2223
]
2324
dynamic = ["version"]
2425

test/plugins/test_hover.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
DOC_URI = uris.from_fs_path(__file__)
1111
DOC = """
1212
13-
def main():
13+
def main(a: float, b: float):
1414
\"\"\"hello world\"\"\"
1515
pass
1616
"""
@@ -79,13 +79,47 @@ def test_hover(workspace) -> None:
7979

8080
doc = Document(DOC_URI, workspace, DOC)
8181

82-
contents = {"kind": "markdown", "value": "```python\nmain()\n```\n\n\nhello world"}
82+
contents = {
83+
"kind": "markdown",
84+
"value": "```python\nmain(a: float, b: float)\n```\n\n\nhello world",
85+
}
8386

8487
assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position)
8588

8689
assert {"contents": ""} == pylsp_hover(doc._config, doc, no_hov_position)
8790

8891

92+
def test_hover_signature_formatting(workspace) -> None:
93+
# Over 'main' in def main():
94+
hov_position = {"line": 2, "character": 6}
95+
96+
doc = Document(DOC_URI, workspace, DOC)
97+
# setting low line length should trigger reflow to multiple lines
98+
doc._config.update({"signature": {"line_length": 10}})
99+
100+
contents = {
101+
"kind": "markdown",
102+
"value": "```python\nmain(\n a: float,\n b: float,\n)\n```\n\n\nhello world",
103+
}
104+
105+
assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position)
106+
107+
108+
def test_hover_signature_formatting_opt_out(workspace) -> None:
109+
# Over 'main' in def main():
110+
hov_position = {"line": 2, "character": 6}
111+
112+
doc = Document(DOC_URI, workspace, DOC)
113+
doc._config.update({"signature": {"line_length": 10, "formatter": None}})
114+
115+
contents = {
116+
"kind": "markdown",
117+
"value": "```python\nmain(a: float, b: float)\n```\n\n\nhello world",
118+
}
119+
120+
assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position)
121+
122+
89123
def test_document_path_hover(workspace_other_root_path, tmpdir) -> None:
90124
# Create a dummy module out of the workspace's root_path and try to get
91125
# a definition on it in another file placed next to it.

0 commit comments

Comments
 (0)