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
importos
88
importpathlib
99
importre
10+
importsubprocess
11+
importsys
1012
importthreading
1113
importtime
1214
fromtypingimportOptional
@@ -57,7 +59,7 @@ def run():
5759

5860

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

6264
defdecorator(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+
classFormatter:
215+
command: list[str]
216+
217+
@property
218+
defis_installed(self) ->bool:
219+
"""Returns whether formatter is available"""
220+
ifnothasattr(self, "_is_installed"):
221+
self._is_installed=self._is_available_via_cli()
222+
returnself._is_installed
223+
224+
defformat(self, code: str, line_length: int) ->str:
225+
"""Formats code"""
226+
returnsubprocess.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+
returnTrue
250+
exceptsubprocess.CalledProcessError:
251+
returnFalse
252+
253+
254+
classRuffFormatter(Formatter):
255+
command= ["ruff", "format"]
256+
257+
258+
classBlackFormatter(Formatter):
259+
command= ["black"]
260+
261+
262+
formatters={"ruff": RuffFormatter(), "black": BlackFormatter()}
263+
264+
265+
defformat_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+
ifformatter.is_installed:
271+
try:
272+
return (
273+
formatter.format(as_func, line_length=line_length)
274+
.removeprefix("def ")
275+
.removesuffix(":\n pass")
276+
)
277+
exceptsubprocess.CalledProcessErrorase:
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+
returnsignature
285+
286+
287+
defconvert_signatures_to_markdown(signatures: list[str], config: dict) ->str:
288+
signature_formatter=config.get("formatter", "black")
289+
ifsignature_formatter:
290+
signatures= [
291+
format_signature(
292+
signature, signature_formatter=signature_formatter, config=config
293+
)
294+
forsignatureinsignatures
295+
]
296+
returnwrap_signature("\n".join(signatures))
297+
298+
212299
defformat_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
ifsignatures:
235-
value=wrap_signature("\n".join(signatures)) +"\n\n"+value
325+
wrapped_signatures=convert_signatures_to_markdown(
326+
signatures, config=signature_configor{}
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
defpylsp_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] ifsignatureelseNone,
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
fori, cinenumerate(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-
defpylsp_completion_item_resolve(config, completion_item, document):
144+
defpylsp_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
ifshared_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
returncompletion_item
157170

158171

@@ -207,13 +220,14 @@ def use_snippets(document, position):
207220
returnexpr_typenotin_IMPORTSandnot (expr_typein_ERRORSand"import"incode)
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() forsignatureind.get_signatures()],
216229
markup_kind=markup_kind,
230+
signature_config=signature_config,
217231
)
218232
exceptException:
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
ifresolve:
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
ifd.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+
deftest_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+
deftest_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
deftest_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)