From bb6005e6cbfb6bd5f0ef227b268b39fbbcc8ed07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Sat, 2 Apr 2022 16:58:40 +0200 Subject: [PATCH] refactor: Stop using deprecated base classes --- src/mkdocstrings_handlers/python/__init__.py | 4 + src/mkdocstrings_handlers/python/handler.py | 184 +++++++++++++++++- src/mkdocstrings_handlers/python/rendering.py | 148 ++++++++++++++ 3 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 src/mkdocstrings_handlers/python/rendering.py 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/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/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