diff --git a/src/mkdocstrings_handlers/python/__init__.py b/src/mkdocstrings_handlers/python/__init__.py index 4823a66..706d85e 100644 --- a/src/mkdocstrings_handlers/python/__init__.py +++ b/src/mkdocstrings_handlers/python/__init__.py @@ -3,3 +3,7 @@ from mkdocstrings_handlers.python.handler import get_handler __all__ = ["get_handler"] # noqa: WPS410 + +# TODO: CSS classes everywhere in templates +# TODO: name normalization (filenames, Jinja2 variables, HTML tags, CSS classes) +# TODO: Jinja2 blocks everywhere in templates diff --git a/src/mkdocstrings_handlers/python/collector.py b/src/mkdocstrings_handlers/python/collector.py deleted file mode 100644 index 2fcc30e..0000000 --- a/src/mkdocstrings_handlers/python/collector.py +++ /dev/null @@ -1,92 +0,0 @@ -"""This module implements a collector for the Python language. - -It collects data with [Griffe](https://github.com/pawamoy/griffe). -""" - -from __future__ import annotations - -from collections import ChainMap -from contextlib import suppress - -from griffe.agents.extensions import load_extensions -from griffe.collections import LinesCollection, ModulesCollection -from griffe.docstrings.parsers import Parser -from griffe.exceptions import AliasResolutionError -from griffe.loader import GriffeLoader -from mkdocstrings.handlers.base import BaseCollector, CollectionError, CollectorItem -from mkdocstrings.loggers import get_logger - -logger = get_logger(__name__) - - -class PythonCollector(BaseCollector): - """The class responsible collecting objects data.""" - - default_config: dict = {"docstring_style": "google", "docstring_options": {}} - """The default selection options. - - Option | Type | Description | Default - ------ | ---- | ----------- | ------- - **`docstring_style`** | `"google" | "numpy" | "sphinx" | None` | The docstring style to use. | `"google"` - **`docstring_options`** | `dict[str, Any]` | The options for the docstring parser. | `{}` - """ - - fallback_config: dict = {"fallback": True} - - def __init__(self) -> None: - """Initialize the collector.""" - self._modules_collection: ModulesCollection = ModulesCollection() - self._lines_collection: LinesCollection = LinesCollection() - - def collect(self, identifier: str, config: dict) -> CollectorItem: # noqa: WPS231 - """Collect the documentation tree given an identifier and selection options. - - Arguments: - identifier: The dotted-path of a Python object available in the Python path. - config: Selection options, used to alter the data collection done by Griffe. - - Raises: - CollectionError: When there was a problem collecting the object documentation. - - Returns: - The collected object-tree. - """ - module_name = identifier.split(".", 1)[0] - unknown_module = module_name not in self._modules_collection - if config.get("fallback", False) and unknown_module: - raise CollectionError("Not loading additional modules during fallback") - - final_config = ChainMap(config, self.default_config) - parser_name = final_config["docstring_style"] - parser_options = final_config["docstring_options"] - parser = parser_name and Parser(parser_name) - - if unknown_module: - loader = GriffeLoader( - extensions=load_extensions(final_config.get("extensions", [])), - docstring_parser=parser, - docstring_options=parser_options, - modules_collection=self._modules_collection, - lines_collection=self._lines_collection, - ) - try: - loader.load_module(module_name) - except ImportError as error: - raise CollectionError(str(error)) from error - - unresolved, iterations = loader.resolve_aliases(only_exported=True, only_known_modules=True) - if unresolved: - logger.warning(f"{len(unresolved)} aliases were still unresolved after {iterations} iterations") - - try: - doc_object = self._modules_collection[identifier] - except KeyError as error: # noqa: WPS440 - raise CollectionError(f"{identifier} could not be found") from error - - if not unknown_module: - with suppress(AliasResolutionError): - if doc_object.docstring is not None: - doc_object.docstring.parser = parser - doc_object.docstring.parser_options = parser_options - - return doc_object diff --git a/src/mkdocstrings_handlers/python/handler.py b/src/mkdocstrings_handlers/python/handler.py index b1018e7..84cfafc 100644 --- a/src/mkdocstrings_handlers/python/handler.py +++ b/src/mkdocstrings_handlers/python/handler.py @@ -1,15 +1,27 @@ """This module implements a handler for the Python language.""" +from __future__ import annotations + import posixpath +from collections import ChainMap +from contextlib import suppress from typing import Any, BinaryIO, Iterator, Optional, Tuple +from griffe.agents.extensions import load_extensions +from griffe.collections import LinesCollection, ModulesCollection +from griffe.docstrings.parsers import Parser +from griffe.exceptions import AliasResolutionError +from griffe.loader import GriffeLoader from griffe.logger import patch_loggers -from mkdocstrings.handlers.base import BaseHandler +from markdown import Markdown +from mkdocstrings.extension import PluginError +from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem from mkdocstrings.inventory import Inventory from mkdocstrings.loggers import get_logger -from mkdocstrings_handlers.python.collector import PythonCollector -from mkdocstrings_handlers.python.renderer import PythonRenderer +from mkdocstrings_handlers.python import rendering + +logger = get_logger(__name__) patch_loggers(get_logger) @@ -21,10 +33,82 @@ class PythonHandler(BaseHandler): domain: The cross-documentation domain/language for this handler. enable_inventory: Whether this handler is interested in enabling the creation of the `objects.inv` Sphinx inventory file. + fallback_theme: The fallback theme. + fallback_config: The configuration used to collect item during autorefs fallback. + default_collection_config: The default rendering options, + see [`default_collection_config`][mkdocstrings_handlers.python.handler.PythonHandler.default_collection_config]. + default_rendering_config: The default rendering options, + see [`default_rendering_config`][mkdocstrings_handlers.python.handler.PythonHandler.default_rendering_config]. """ domain: str = "py" # to match Sphinx's default domain enable_inventory: bool = True + fallback_theme = "material" + fallback_config: dict = {"fallback": True} + default_collection_config: dict = {"docstring_style": "google", "docstring_options": {}} + """The default collection options. + + Option | Type | Description | Default + ------ | ---- | ----------- | ------- + **`docstring_style`** | `"google" | "numpy" | "sphinx" | None` | The docstring style to use. | `"google"` + **`docstring_options`** | `dict[str, Any]` | The options for the docstring parser. | `{}` + """ + default_rendering_config: dict = { + "show_root_heading": False, + "show_root_toc_entry": True, + "show_root_full_path": True, + "show_root_members_full_path": False, + "show_object_full_path": False, + "show_category_heading": False, + "show_if_no_docstring": False, + "show_signature": True, + "show_signature_annotations": False, + "separate_signature": False, + "line_length": 60, + "merge_init_into_class": False, + "show_source": True, + "show_bases": True, + "show_submodules": True, + "group_by_category": True, + "heading_level": 2, + "members_order": rendering.Order.alphabetical.value, + "docstring_section_style": "table", + } + """The default rendering options. + + Option | Type | Description | Default + ------ | ---- | ----------- | ------- + **`show_root_heading`** | `bool` | Show the heading of the object at the root of the documentation tree. | `False` + **`show_root_toc_entry`** | `bool` | If the root heading is not shown, at least add a ToC entry for it. | `True` + **`show_root_full_path`** | `bool` | Show the full Python path for the root object heading. | `True` + **`show_object_full_path`** | `bool` | Show the full Python path of every object. | `False` + **`show_root_members_full_path`** | `bool` | Show the full Python path of objects that are children of the root object (for example, classes in a module). When False, `show_object_full_path` overrides. | `False` + **`show_category_heading`** | `bool` | When grouped by categories, show a heading for each category. | `False` + **`show_if_no_docstring`** | `bool` | Show the object heading even if it has no docstring or children with docstrings. | `False` + **`show_signature`** | `bool` | Show method and function signatures. | `True` + **`show_signature_annotations`** | `bool` | Show the type annotations in method and function signatures. | `False` + **`separate_signature`** | `bool` | Whether to put the whole signature in a code block below the heading. | `False` + **`line_length`** | `int` | Maximum line length when formatting code. | `60` + **`merge_init_into_class`** | `bool` | Whether to merge the `__init__` method into the class' signature and docstring. | `False` + **`show_source`** | `bool` | Show the source code of this object. | `True` + **`show_bases`** | `bool` | Show the base classes of a class. | `True` + **`show_submodules`** | `bool` | When rendering a module, show its submodules recursively. | `True` + **`group_by_category`** | `bool` | Group the object's children by categories: attributes, classes, functions, methods, and modules. | `True` + **`heading_level`** | `int` | The initial heading level to use. | `2` + **`members_order`** | `str` | The members ordering to use. Options: `alphabetical` - order by the members names, `source` - order members as they appear in the source file. | `alphabetical` + **`docstring_section_style`** | `str` | The style used to render docstring sections. Options: `table`, `list`, `spacy`. | `table` + """ # noqa: E501 + + def __init__(self, *args, **kwargs) -> None: + """Initialize the handler. + + Parameters: + *args: Handler name, theme and custom templates. + **kwargs: Same thing, but with keyword arguments. + """ + super().__init__(*args, **kwargs) + self._modules_collection: ModulesCollection = ModulesCollection() + self._lines_collection: LinesCollection = LinesCollection() @classmethod def load_inventory( @@ -53,6 +137,95 @@ def load_inventory( for item in Inventory.parse_sphinx(in_file, domain_filter=("py",)).values(): # noqa: WPS526 yield item.name, posixpath.join(base_url, item.uri) + def collect(self, identifier: str, config: dict) -> CollectorItem: # noqa: WPS231 + """Collect the documentation tree given an identifier and selection options. + + Arguments: + identifier: The dotted-path of a Python object available in the Python path. + config: Selection options, used to alter the data collection done by `pytkdocs`. + + Raises: + CollectionError: When there was a problem collecting the object documentation. + + Returns: + The collected object-tree. + """ + module_name = identifier.split(".", 1)[0] + unknown_module = module_name not in self._modules_collection + if config.get("fallback", False) and unknown_module: + raise CollectionError("Not loading additional modules during fallback") + + final_config = ChainMap(config, self.default_collection_config) + parser_name = final_config["docstring_style"] + parser_options = final_config["docstring_options"] + parser = parser_name and Parser(parser_name) + + if unknown_module: + loader = GriffeLoader( + extensions=load_extensions(final_config.get("extensions", [])), + docstring_parser=parser, + docstring_options=parser_options, + modules_collection=self._modules_collection, + lines_collection=self._lines_collection, + ) + try: + loader.load_module(module_name) + except ImportError as error: + raise CollectionError(str(error)) from error + + unresolved, iterations = loader.resolve_aliases(only_exported=True, only_known_modules=True) + if unresolved: + logger.warning(f"{len(unresolved)} aliases were still unresolved after {iterations} iterations") + + try: + doc_object = self._modules_collection[identifier] + except KeyError as error: # noqa: WPS440 + raise CollectionError(f"{identifier} could not be found") from error + + if not unknown_module: + with suppress(AliasResolutionError): + if doc_object.docstring is not None: + doc_object.docstring.parser = parser + doc_object.docstring.parser_options = parser_options + + return doc_object + + def render(self, data: CollectorItem, config: dict) -> str: # noqa: D102 (ignore missing docstring) + final_config = ChainMap(config, self.default_rendering_config) + + template = self.env.get_template(f"{data.kind.value}.html") + + # Heading level is a "state" variable, that will change at each step + # of the rendering recursion. Therefore, it's easier to use it as a plain value + # than as an item in a dictionary. + heading_level = final_config["heading_level"] + try: + final_config["members_order"] = rendering.Order(final_config["members_order"]) + except ValueError: + choices = "', '".join(item.value for item in rendering.Order) + raise PluginError(f"Unknown members_order '{final_config['members_order']}', choose between '{choices}'.") + + return template.render( + **{"config": final_config, data.kind.value: data, "heading_level": heading_level, "root": True}, + ) + + def update_env(self, md: Markdown, config: dict) -> None: # noqa: D102 (ignore missing docstring) + super().update_env(md, config) + self.env.trim_blocks = True + self.env.lstrip_blocks = True + self.env.keep_trailing_newline = False + self.env.filters["crossref"] = rendering.do_crossref + self.env.filters["multi_crossref"] = rendering.do_multi_crossref + self.env.filters["order_members"] = rendering.do_order_members + self.env.filters["format_code"] = rendering.do_format_code + self.env.filters["format_signature"] = rendering.do_format_signature + + def get_anchors(self, data: CollectorItem) -> list[str]: # noqa: D102 (ignore missing docstring) + try: + return list({data.path, data.canonical_path, *data.aliases}) + except AliasResolutionError: + return [data.path] + def get_handler( theme: str, # noqa: W0613 (unused argument config) @@ -69,7 +242,4 @@ def get_handler( Returns: An instance of `PythonHandler`. """ - return PythonHandler( - collector=PythonCollector(), - renderer=PythonRenderer("python", theme, custom_templates), - ) + return PythonHandler("python", theme, custom_templates) diff --git a/src/mkdocstrings_handlers/python/renderer.py b/src/mkdocstrings_handlers/python/renderer.py deleted file mode 100644 index 93c9119..0000000 --- a/src/mkdocstrings_handlers/python/renderer.py +++ /dev/null @@ -1,249 +0,0 @@ -"""This module implements a renderer for the Python language.""" - -from __future__ import annotations - -import enum -import re -import sys -from collections import ChainMap -from functools import lru_cache -from typing import Any, Sequence - -from griffe.dataclasses import Alias, Object -from griffe.exceptions import AliasResolutionError -from markdown import Markdown -from markupsafe import Markup -from mkdocstrings.extension import PluginError -from mkdocstrings.handlers.base import BaseRenderer, CollectorItem -from mkdocstrings.loggers import get_logger - -logger = get_logger(__name__) -# TODO: CSS classes everywhere in templates -# TODO: name normalization (filenames, Jinja2 variables, HTML tags, CSS classes) -# TODO: Jinja2 blocks everywhere in templates - - -class Order(enum.Enum): - """Enumeration for the possible members ordering.""" - - alphabetical = "alphabetical" - source = "source" - - -def _sort_key_alphabetical(item: CollectorItem) -> Any: - # chr(sys.maxunicode) is a string that contains the final unicode - # character, so if 'name' isn't found on the object, the item will go to - # the end of the list. - return item.name or chr(sys.maxunicode) - - -def _sort_key_source(item: CollectorItem) -> Any: - # if 'lineno' is none, the item will go to the start of the list. - return item.lineno if item.lineno is not None else -1 - - -order_map = { - Order.alphabetical: _sort_key_alphabetical, - Order.source: _sort_key_source, -} - - -class PythonRenderer(BaseRenderer): - """The class responsible for loading Jinja templates and rendering them. - - It defines some configuration options, implements the `render` method, - and overrides the `update_env` method of the [`BaseRenderer` class][mkdocstrings.handlers.base.BaseRenderer]. - - Attributes: - fallback_theme: The theme to fallback to. - default_config: The default rendering options, - see [`default_config`][mkdocstrings_handlers.python.renderer.PythonRenderer.default_config]. - """ - - fallback_theme = "material" - - default_config: dict = { - "show_root_heading": False, - "show_root_toc_entry": True, - "show_root_full_path": True, - "show_root_members_full_path": False, - "show_object_full_path": False, - "show_category_heading": False, - "show_if_no_docstring": False, - "show_signature": True, - "show_signature_annotations": False, - "separate_signature": False, - "line_length": 60, - "merge_init_into_class": False, - "show_source": True, - "show_bases": True, - "show_submodules": True, - "group_by_category": True, - "heading_level": 2, - "members_order": Order.alphabetical.value, - "docstring_section_style": "table", - } - """The default rendering options. - - Option | Type | Description | Default - ------ | ---- | ----------- | ------- - **`show_root_heading`** | `bool` | Show the heading of the object at the root of the documentation tree. | `False` - **`show_root_toc_entry`** | `bool` | If the root heading is not shown, at least add a ToC entry for it. | `True` - **`show_root_full_path`** | `bool` | Show the full Python path for the root object heading. | `True` - **`show_object_full_path`** | `bool` | Show the full Python path of every object. | `False` - **`show_root_members_full_path`** | `bool` | Show the full Python path of objects that are children of the root object (for example, classes in a module). When False, `show_object_full_path` overrides. | `False` - **`show_category_heading`** | `bool` | When grouped by categories, show a heading for each category. | `False` - **`show_if_no_docstring`** | `bool` | Show the object heading even if it has no docstring or children with docstrings. | `False` - **`show_signature`** | `bool` | Show method and function signatures. | `True` - **`show_signature_annotations`** | `bool` | Show the type annotations in method and function signatures. | `False` - **`separate_signature`** | `bool` | Whether to put the whole signature in a code block below the heading. | `False` - **`line_length`** | `int` | Maximum line length when formatting code. | `60` - **`merge_init_into_class`** | `bool` | Whether to merge the `__init__` method into the class' signature and docstring. | `False` - **`show_source`** | `bool` | Show the source code of this object. | `True` - **`show_bases`** | `bool` | Show the base classes of a class. | `True` - **`show_submodules`** | `bool` | When rendering a module, show its submodules recursively. | `True` - **`group_by_category`** | `bool` | Group the object's children by categories: attributes, classes, functions, methods, and modules. | `True` - **`heading_level`** | `int` | The initial heading level to use. | `2` - **`members_order`** | `str` | The members ordering to use. Options: `alphabetical` - order by the members names, `source` - order members as they appear in the source file. | `alphabetical` - **`docstring_section_style`** | `str` | The style used to render docstring sections. Options: `table`, `list`, `spacy`. | `table` - """ # noqa: E501 - - def render(self, data: CollectorItem, config: dict) -> str: # noqa: D102 (ignore missing docstring) - final_config = ChainMap(config, self.default_config) - - template = self.env.get_template(f"{data.kind.value}.html") - - # Heading level is a "state" variable, that will change at each step - # of the rendering recursion. Therefore, it's easier to use it as a plain value - # than as an item in a dictionary. - heading_level = final_config["heading_level"] - try: - final_config["members_order"] = Order(final_config["members_order"]) - except ValueError: - choices = "', '".join(item.value for item in Order) - raise PluginError(f"Unknown members_order '{final_config['members_order']}', choose between '{choices}'.") - - return template.render( - **{"config": final_config, data.kind.value: data, "heading_level": heading_level, "root": True}, - ) - - def get_anchors(self, data: CollectorItem) -> list[str]: # noqa: D102 (ignore missing docstring) - try: - return list({data.path, data.canonical_path, *data.aliases}) - except AliasResolutionError: - return [data.path] - - def update_env(self, md: Markdown, config: dict) -> None: # noqa: D102 (ignore missing docstring) - super().update_env(md, config) - self.env.trim_blocks = True - self.env.lstrip_blocks = True - self.env.keep_trailing_newline = False - self.env.filters["crossref"] = self.do_crossref - self.env.filters["multi_crossref"] = self.do_multi_crossref - self.env.filters["order_members"] = self.do_order_members - self.env.filters["format_code"] = self.do_format_code - self.env.filters["format_signature"] = self.do_format_signature - - def do_format_code(self, code: str, line_length: int) -> str: - """Format code using Black. - - Parameters: - code: The code to format. - line_length: The line length to give to Black. - - Returns: - The same code, formatted. - """ - code = code.strip() - if len(code) < line_length: - return code - formatter = _get_black_formatter() - return formatter(code, line_length) - - def do_format_signature(self, signature: str, line_length: int) -> str: - """Format a signature using Black. - - Parameters: - signature: The signature to format. - line_length: The line length to give to Black. - - Returns: - The same code, formatted. - """ - code = signature.strip() - if len(code) < line_length: - return code - formatter = _get_black_formatter() - formatted = formatter(f"def {code}: pass", line_length) - # remove starting `def ` and trailing `: pass` - return formatted[4:-5].strip()[:-1] - - def do_order_members(self, members: Sequence[Object | Alias], order: Order) -> Sequence[Object | Alias]: - """Order members given an ordering method. - - Parameters: - members: The members to order. - order: The ordering method. - - Returns: - The same members, ordered. - """ - return sorted(members, key=order_map[order]) - - def do_crossref(self, path: str, brief: bool = True) -> Markup: - """Filter to create cross-references. - - Parameters: - path: The path to link to. - brief: Show only the last part of the path, add full path as hover. - - Returns: - Markup text. - """ - full_path = path - if brief: - path = full_path.split(".")[-1] - return Markup("{path}").format( - full_path=full_path, path=path - ) - - def do_multi_crossref(self, text: str, code: bool = True) -> Markup: - """Filter to create cross-references. - - Parameters: - text: The text to scan. - code: Whether to wrap the result in a code tag. - - Returns: - Markup text. - """ - group_number = 0 - variables = {} - - def repl(match): # noqa: WPS430 - nonlocal group_number # noqa: WPS420 - group_number += 1 - path = match.group() - path_var = f"path{group_number}" - variables[path_var] = path - return f"{{{path_var}}}" - - text = re.sub(r"([\w.]+)", repl, text) - if code: - text = f"{text}" - return Markup(text).format(**variables) - - -@lru_cache(maxsize=1) -def _get_black_formatter(): - try: - from black import Mode, format_str - except ModuleNotFoundError: - logger.warning("Formatting signatures requires Black to be installed.") - return lambda text, _: text - - def formatter(code, line_length): # noqa: WPS430 - mode = Mode(line_length=line_length) - return format_str(code, mode=mode) - - return formatter diff --git a/src/mkdocstrings_handlers/python/rendering.py b/src/mkdocstrings_handlers/python/rendering.py new file mode 100644 index 0000000..8a29256 --- /dev/null +++ b/src/mkdocstrings_handlers/python/rendering.py @@ -0,0 +1,148 @@ +"""This module implements rendering utilities.""" + +from __future__ import annotations + +import enum +import re +import sys +from functools import lru_cache +from typing import Any, Sequence + +from griffe.dataclasses import Alias, Object +from markupsafe import Markup +from mkdocstrings.handlers.base import CollectorItem +from mkdocstrings.loggers import get_logger + +logger = get_logger(__name__) + + +class Order(enum.Enum): + """Enumeration for the possible members ordering.""" + + alphabetical = "alphabetical" + source = "source" + + +def _sort_key_alphabetical(item: CollectorItem) -> Any: + # chr(sys.maxunicode) is a string that contains the final unicode + # character, so if 'name' isn't found on the object, the item will go to + # the end of the list. + return item.name or chr(sys.maxunicode) + + +def _sort_key_source(item: CollectorItem) -> Any: + # if 'lineno' is none, the item will go to the start of the list. + return item.lineno if item.lineno is not None else -1 + + +order_map = { + Order.alphabetical: _sort_key_alphabetical, + Order.source: _sort_key_source, +} + + +def do_format_code(code: str, line_length: int) -> str: + """Format code using Black. + + Parameters: + code: The code to format. + line_length: The line length to give to Black. + + Returns: + The same code, formatted. + """ + code = code.strip() + if len(code) < line_length: + return code + formatter = _get_black_formatter() + return formatter(code, line_length) + + +def do_format_signature(signature: str, line_length: int) -> str: + """Format a signature using Black. + + Parameters: + signature: The signature to format. + line_length: The line length to give to Black. + + Returns: + The same code, formatted. + """ + code = signature.strip() + if len(code) < line_length: + return code + formatter = _get_black_formatter() + formatted = formatter(f"def {code}: pass", line_length) + # remove starting `def ` and trailing `: pass` + return formatted[4:-5].strip()[:-1] + + +def do_order_members(members: Sequence[Object | Alias], order: Order) -> Sequence[Object | Alias]: + """Order members given an ordering method. + + Parameters: + members: The members to order. + order: The ordering method. + + Returns: + The same members, ordered. + """ + return sorted(members, key=order_map[order]) + + +def do_crossref(path: str, brief: bool = True) -> Markup: + """Filter to create cross-references. + + Parameters: + path: The path to link to. + brief: Show only the last part of the path, add full path as hover. + + Returns: + Markup text. + """ + full_path = path + if brief: + path = full_path.split(".")[-1] + return Markup("{path}").format(full_path=full_path, path=path) + + +def do_multi_crossref(text: str, code: bool = True) -> Markup: + """Filter to create cross-references. + + Parameters: + text: The text to scan. + code: Whether to wrap the result in a code tag. + + Returns: + Markup text. + """ + group_number = 0 + variables = {} + + def repl(match): # noqa: WPS430 + nonlocal group_number # noqa: WPS420 + group_number += 1 + path = match.group() + path_var = f"path{group_number}" + variables[path_var] = path + return f"{{{path_var}}}" + + text = re.sub(r"([\w.]+)", repl, text) + if code: + text = f"{text}" + return Markup(text).format(**variables) + + +@lru_cache(maxsize=1) +def _get_black_formatter(): + try: + from black import Mode, format_str + except ModuleNotFoundError: + logger.warning("Formatting signatures requires Black to be installed.") + return lambda text, _: text + + def formatter(code, line_length): # noqa: WPS430 + mode = Mode(line_length=line_length) + return format_str(code, mode=mode) + + return formatter