Skip to content

Commit

Permalink
feat: Allow using Ruff to format signatures and attribute values
Browse files Browse the repository at this point in the history
PR-216: #216
  • Loading branch information
DarkaMaul authored Dec 19, 2024
1 parent c4506f0 commit d67215c
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 22 deletions.
1 change: 1 addition & 0 deletions docs/.glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
[Spacy's documentation]: https://spacy.io/api/doc/
[Black]: https://pypi.org/project/black/
[Material for MkDocs]: https://squidfunk.github.io/mkdocs-material
[Ruff]: https://docs.astral.sh/ruff

*[ToC]: Table of Contents
2 changes: 1 addition & 1 deletion docs/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"default": false
},
"separate_signature": {
"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.",
"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.",
"markdownDescription": "https://mkdocstrings.github.io/python/usage/configuration/signatures/#separate_signature",
"type": "boolean",
"default": false
Expand Down
18 changes: 14 additions & 4 deletions docs/usage/configuration/signatures.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,15 @@ def convert(text: str, md: Markdown) -> Markup:
Maximum line length when formatting code/signatures.

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

If Black is not installed, the handler issues an INFO log once.
The handler will automatically try to format using :

1. [Black]
2. [Ruff]

If a formatter is not found, the handler issues an INFO log once.

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

When separating signatures from headings,
the Python handler will try to format the signatures using [Black] and
the Python handler will try to format the signatures using a formatter and
the specified [line length][line_length].

If Black is not installed, the handler issues an INFO log once.
The handler will automatically try to format using :

1. [Black]
2. [Ruff]

If a formatter is not found, the handler issues an INFO log once.

```yaml title="in mkdocs.yml (global configuration)"
plugins:
Expand Down
2 changes: 1 addition & 1 deletion src/mkdocstrings_handlers/python/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ class PythonHandler(BaseHandler):
show_signature_annotations (bool): Show the type annotations in methods and functions signatures. Default: `False`.
signature_crossrefs (bool): Whether to render cross-references for type annotations in signatures. Default: `False`.
separate_signature (bool): Whether to put the whole signature in a code block below the heading.
If Black is installed, the signature is also formatted using it. Default: `False`.
If a formatter (Black or Ruff) is installed, the signature is also formatted using it. Default: `False`.
unwrap_annotated (bool): Whether to unwrap `Annotated` types to show only the type without the annotations. Default: `False`.
modernize_annotations (bool): Whether to modernize annotations, for example `Optional[str]` into `str | None`. Default: `False`.
"""
Expand Down
70 changes: 59 additions & 11 deletions src/mkdocstrings_handlers/python/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import random
import re
import string
import subprocess
import sys
import warnings
from functools import lru_cache
Expand Down Expand Up @@ -71,19 +72,19 @@ def _sort_key_source(item: CollectorItem) -> Any:


def do_format_code(code: str, line_length: int) -> str:
"""Format code using Black.
"""Format code.
Parameters:
code: The code to format.
line_length: The line length to give to Black.
line_length: The line length.
Returns:
The same code, formatted.
"""
code = code.strip()
if len(code) < line_length:
return code
formatter = _get_black_formatter()
formatter = _get_formatter()
return formatter(code, line_length)


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

Expand All @@ -137,13 +138,13 @@ def do_format_signature(
annotations: bool | None = None,
crossrefs: bool = False, # noqa: ARG001
) -> str:
"""Format a signature using Black.
"""Format a signature.
Parameters:
context: Jinja context, passed automatically.
callable_path: The path of the callable we render the signature of.
function: The function we render the signature of.
line_length: The line length to give to Black.
line_length: The line length.
annotations: Whether to show type annotations.
crossrefs: Whether to cross-reference types in the signature.
Expand Down Expand Up @@ -199,13 +200,13 @@ def do_format_attribute(
*,
crossrefs: bool = False, # noqa: ARG001
) -> str:
"""Format an attribute using Black.
"""Format an attribute.
Parameters:
context: Jinja context, passed automatically.
attribute_path: The path of the callable we render the signature of.
attribute: The attribute we render the signature of.
line_length: The line length to give to Black.
line_length: The line length.
crossrefs: Whether to cross-reference types in the signature.
Returns:
Expand Down Expand Up @@ -434,12 +435,59 @@ def do_filter_objects(


@lru_cache(maxsize=1)
def _get_black_formatter() -> Callable[[str, int], str]:
def _get_formatter() -> Callable[[str, int], str]:
for formatter_function in [
_get_black_formatter,
_get_ruff_formatter,
]:
if (formatter := formatter_function()) is not None:
return formatter

logger.info("Formatting signatures requires either Black or Ruff to be installed.")
return lambda text, _: text


def _get_ruff_formatter() -> Callable[[str, int], str] | None:
try:
from ruff.__main__ import find_ruff_bin
except ImportError:
return None

try:
ruff_bin = find_ruff_bin()
except FileNotFoundError:
ruff_bin = "ruff"

def formatter(code: str, line_length: int) -> str:
try:
completed_process = subprocess.run( # noqa: S603
[
ruff_bin,
"format",
"--config",
f"line-length={line_length}",
"--stdin-filename",
"file.py",
"-",
],
check=True,
capture_output=True,
text=True,
input=code,
)
except subprocess.CalledProcessError:
return code
else:
return completed_process.stdout

return formatter


def _get_black_formatter() -> Callable[[str, int], str] | None:
try:
from black import InvalidInput, Mode, format_str
except ModuleNotFoundError:
logger.info("Formatting signatures requires Black to be installed.")
return lambda text, _: text
return None

def formatter(code: str, line_length: int) -> str:
mode = Mode(line_length=line_length)
Expand Down
18 changes: 13 additions & 5 deletions tests/test_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Callable

import pytest
from griffe import ModulesCollection, temporary_visited_module
Expand All @@ -22,22 +22,30 @@
"aaaaa(bbbbb, ccccc=1) + ddddd.eeeee[ffff] or {ggggg: hhhhh, iiiii: jjjjj}",
],
)
def test_format_code(code: str) -> None:
"""Assert code can be Black-formatted.
@pytest.mark.parametrize(
"formatter",
[
rendering._get_black_formatter(),
rendering._get_ruff_formatter(),
rendering._get_formatter(),
],
)
def test_format_code(code: str, formatter: Callable[[str, int], str]) -> None:
"""Assert code can be formatted.
Parameters:
code: Code to format.
"""
for length in (5, 100):
assert rendering.do_format_code(code, length)
assert formatter(code, length)


@pytest.mark.parametrize(
("name", "signature"),
[("Class.method", "(param: str = 'hello') -> 'OtherClass'")],
)
def test_format_signature(name: Markup, signature: str) -> None:
"""Assert signatures can be Black-formatted.
"""Assert signatures can be formatted.
Parameters:
signature: Signature to format.
Expand Down

0 comments on commit d67215c

Please sign in to comment.