Skip to content

Commit d67215c

Browse files
authored
feat: Allow using Ruff to format signatures and attribute values
PR-216: #216
1 parent c4506f0 commit d67215c

File tree

6 files changed

+89
-22
lines changed

6 files changed

+89
-22
lines changed

docs/.glossary.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
[Spacy's documentation]: https://spacy.io/api/doc/
99
[Black]: https://pypi.org/project/black/
1010
[Material for MkDocs]: https://squidfunk.github.io/mkdocs-material
11+
[Ruff]: https://docs.astral.sh/ruff
1112

1213
*[ToC]: Table of Contents

docs/schema.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@
145145
"default": false
146146
},
147147
"separate_signature": {
148-
"title": "Whether to put the whole signature in a code block below the heading. If Black is installed, the signature is also formatted using it.",
148+
"title": "Whether to put the whole signature in a code block below the heading. If a formatter (Black or Ruff) is installed, the signature is also formatted using it.",
149149
"markdownDescription": "https://mkdocstrings.github.io/python/usage/configuration/signatures/#separate_signature",
150150
"type": "boolean",
151151
"default": false

docs/usage/configuration/signatures.md

+14-4
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,15 @@ def convert(text: str, md: Markdown) -> Markup:
154154
Maximum line length when formatting code/signatures.
155155

156156
When separating signatures from headings with the [`separate_signature`][] option,
157-
the Python handler will try to format the signatures using [Black] and
157+
the Python handler will try to format the signatures using a formatter and
158158
the specified line length.
159159

160-
If Black is not installed, the handler issues an INFO log once.
160+
The handler will automatically try to format using :
161+
162+
1. [Black]
163+
2. [Ruff]
164+
165+
If a formatter is not found, the handler issues an INFO log once.
161166

162167
```yaml title="in mkdocs.yml (global configuration)"
163168
plugins:
@@ -380,10 +385,15 @@ function(param1, param2=None)
380385
Whether to put the whole signature in a code block below the heading.
381386

382387
When separating signatures from headings,
383-
the Python handler will try to format the signatures using [Black] and
388+
the Python handler will try to format the signatures using a formatter and
384389
the specified [line length][line_length].
385390

386-
If Black is not installed, the handler issues an INFO log once.
391+
The handler will automatically try to format using :
392+
393+
1. [Black]
394+
2. [Ruff]
395+
396+
If a formatter is not found, the handler issues an INFO log once.
387397

388398
```yaml title="in mkdocs.yml (global configuration)"
389399
plugins:

src/mkdocstrings_handlers/python/handler.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ class PythonHandler(BaseHandler):
201201
show_signature_annotations (bool): Show the type annotations in methods and functions signatures. Default: `False`.
202202
signature_crossrefs (bool): Whether to render cross-references for type annotations in signatures. Default: `False`.
203203
separate_signature (bool): Whether to put the whole signature in a code block below the heading.
204-
If Black is installed, the signature is also formatted using it. Default: `False`.
204+
If a formatter (Black or Ruff) is installed, the signature is also formatted using it. Default: `False`.
205205
unwrap_annotated (bool): Whether to unwrap `Annotated` types to show only the type without the annotations. Default: `False`.
206206
modernize_annotations (bool): Whether to modernize annotations, for example `Optional[str]` into `str | None`. Default: `False`.
207207
"""

src/mkdocstrings_handlers/python/rendering.py

+59-11
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import random
77
import re
88
import string
9+
import subprocess
910
import sys
1011
import warnings
1112
from functools import lru_cache
@@ -71,19 +72,19 @@ def _sort_key_source(item: CollectorItem) -> Any:
7172

7273

7374
def do_format_code(code: str, line_length: int) -> str:
74-
"""Format code using Black.
75+
"""Format code.
7576
7677
Parameters:
7778
code: The code to format.
78-
line_length: The line length to give to Black.
79+
line_length: The line length.
7980
8081
Returns:
8182
The same code, formatted.
8283
"""
8384
code = code.strip()
8485
if len(code) < line_length:
8586
return code
86-
formatter = _get_black_formatter()
87+
formatter = _get_formatter()
8788
return formatter(code, line_length)
8889

8990

@@ -118,7 +119,7 @@ def _format_signature(name: Markup, signature: str, line_length: int) -> str:
118119
# Black cannot format names with dots, so we replace
119120
# the whole name with a string of equal length
120121
name_length = len(name)
121-
formatter = _get_black_formatter()
122+
formatter = _get_formatter()
122123
formatable = f"def {'x' * name_length}{signature}: pass"
123124
formatted = formatter(formatable, line_length)
124125

@@ -137,13 +138,13 @@ def do_format_signature(
137138
annotations: bool | None = None,
138139
crossrefs: bool = False, # noqa: ARG001
139140
) -> str:
140-
"""Format a signature using Black.
141+
"""Format a signature.
141142
142143
Parameters:
143144
context: Jinja context, passed automatically.
144145
callable_path: The path of the callable we render the signature of.
145146
function: The function we render the signature of.
146-
line_length: The line length to give to Black.
147+
line_length: The line length.
147148
annotations: Whether to show type annotations.
148149
crossrefs: Whether to cross-reference types in the signature.
149150
@@ -199,13 +200,13 @@ def do_format_attribute(
199200
*,
200201
crossrefs: bool = False, # noqa: ARG001
201202
) -> str:
202-
"""Format an attribute using Black.
203+
"""Format an attribute.
203204
204205
Parameters:
205206
context: Jinja context, passed automatically.
206207
attribute_path: The path of the callable we render the signature of.
207208
attribute: The attribute we render the signature of.
208-
line_length: The line length to give to Black.
209+
line_length: The line length.
209210
crossrefs: Whether to cross-reference types in the signature.
210211
211212
Returns:
@@ -434,12 +435,59 @@ def do_filter_objects(
434435

435436

436437
@lru_cache(maxsize=1)
437-
def _get_black_formatter() -> Callable[[str, int], str]:
438+
def _get_formatter() -> Callable[[str, int], str]:
439+
for formatter_function in [
440+
_get_black_formatter,
441+
_get_ruff_formatter,
442+
]:
443+
if (formatter := formatter_function()) is not None:
444+
return formatter
445+
446+
logger.info("Formatting signatures requires either Black or Ruff to be installed.")
447+
return lambda text, _: text
448+
449+
450+
def _get_ruff_formatter() -> Callable[[str, int], str] | None:
451+
try:
452+
from ruff.__main__ import find_ruff_bin
453+
except ImportError:
454+
return None
455+
456+
try:
457+
ruff_bin = find_ruff_bin()
458+
except FileNotFoundError:
459+
ruff_bin = "ruff"
460+
461+
def formatter(code: str, line_length: int) -> str:
462+
try:
463+
completed_process = subprocess.run( # noqa: S603
464+
[
465+
ruff_bin,
466+
"format",
467+
"--config",
468+
f"line-length={line_length}",
469+
"--stdin-filename",
470+
"file.py",
471+
"-",
472+
],
473+
check=True,
474+
capture_output=True,
475+
text=True,
476+
input=code,
477+
)
478+
except subprocess.CalledProcessError:
479+
return code
480+
else:
481+
return completed_process.stdout
482+
483+
return formatter
484+
485+
486+
def _get_black_formatter() -> Callable[[str, int], str] | None:
438487
try:
439488
from black import InvalidInput, Mode, format_str
440489
except ModuleNotFoundError:
441-
logger.info("Formatting signatures requires Black to be installed.")
442-
return lambda text, _: text
490+
return None
443491

444492
def formatter(code: str, line_length: int) -> str:
445493
mode = Mode(line_length=line_length)

tests/test_rendering.py

+13-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import re
66
from dataclasses import dataclass
7-
from typing import TYPE_CHECKING, Any
7+
from typing import TYPE_CHECKING, Any, Callable
88

99
import pytest
1010
from griffe import ModulesCollection, temporary_visited_module
@@ -22,22 +22,30 @@
2222
"aaaaa(bbbbb, ccccc=1) + ddddd.eeeee[ffff] or {ggggg: hhhhh, iiiii: jjjjj}",
2323
],
2424
)
25-
def test_format_code(code: str) -> None:
26-
"""Assert code can be Black-formatted.
25+
@pytest.mark.parametrize(
26+
"formatter",
27+
[
28+
rendering._get_black_formatter(),
29+
rendering._get_ruff_formatter(),
30+
rendering._get_formatter(),
31+
],
32+
)
33+
def test_format_code(code: str, formatter: Callable[[str, int], str]) -> None:
34+
"""Assert code can be formatted.
2735
2836
Parameters:
2937
code: Code to format.
3038
"""
3139
for length in (5, 100):
32-
assert rendering.do_format_code(code, length)
40+
assert formatter(code, length)
3341

3442

3543
@pytest.mark.parametrize(
3644
("name", "signature"),
3745
[("Class.method", "(param: str = 'hello') -> 'OtherClass'")],
3846
)
3947
def test_format_signature(name: Markup, signature: str) -> None:
40-
"""Assert signatures can be Black-formatted.
48+
"""Assert signatures can be formatted.
4149
4250
Parameters:
4351
signature: Signature to format.

0 commit comments

Comments
 (0)