From d67215c976938ef1e169f16dd0b6166067ebd7bc Mon Sep 17 00:00:00 2001 From: dm Date: Thu, 19 Dec 2024 18:11:04 +0100 Subject: [PATCH] feat: Allow using Ruff to format signatures and attribute values PR-216: https://github.com/mkdocstrings/python/pull/216 --- docs/.glossary.md | 1 + docs/schema.json | 2 +- docs/usage/configuration/signatures.md | 18 +++-- src/mkdocstrings_handlers/python/handler.py | 2 +- src/mkdocstrings_handlers/python/rendering.py | 70 ++++++++++++++++--- tests/test_rendering.py | 18 +++-- 6 files changed, 89 insertions(+), 22 deletions(-) diff --git a/docs/.glossary.md b/docs/.glossary.md index 588674fb..917c95c4 100644 --- a/docs/.glossary.md +++ b/docs/.glossary.md @@ -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 diff --git a/docs/schema.json b/docs/schema.json index b4eca004..e1863d26 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -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 diff --git a/docs/usage/configuration/signatures.md b/docs/usage/configuration/signatures.md index e5e4cb88..879db6b1 100644 --- a/docs/usage/configuration/signatures.md +++ b/docs/usage/configuration/signatures.md @@ -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: @@ -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: diff --git a/src/mkdocstrings_handlers/python/handler.py b/src/mkdocstrings_handlers/python/handler.py index 628b56ec..4171fd76 100644 --- a/src/mkdocstrings_handlers/python/handler.py +++ b/src/mkdocstrings_handlers/python/handler.py @@ -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`. """ diff --git a/src/mkdocstrings_handlers/python/rendering.py b/src/mkdocstrings_handlers/python/rendering.py index 5cbd5ebf..085f0c34 100644 --- a/src/mkdocstrings_handlers/python/rendering.py +++ b/src/mkdocstrings_handlers/python/rendering.py @@ -6,6 +6,7 @@ import random import re import string +import subprocess import sys import warnings from functools import lru_cache @@ -71,11 +72,11 @@ 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. @@ -83,7 +84,7 @@ def do_format_code(code: str, line_length: int) -> str: code = code.strip() if len(code) < line_length: return code - formatter = _get_black_formatter() + formatter = _get_formatter() return formatter(code, line_length) @@ -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) @@ -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. @@ -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: @@ -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) diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 1bab29d7..081702f4 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -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 @@ -22,14 +22,22 @@ "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( @@ -37,7 +45,7 @@ def test_format_code(code: str) -> None: [("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.