Skip to content
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

Allow ruff to be used as a formatter #216

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
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
Loading