Skip to content

Commit

Permalink
refactor: Stop using deprecated base classes
Browse files Browse the repository at this point in the history
  • Loading branch information
pawamoy committed Apr 2, 2022
1 parent b946c36 commit bb6005e
Show file tree
Hide file tree
Showing 3 changed files with 329 additions and 7 deletions.
4 changes: 4 additions & 0 deletions src/mkdocstrings_handlers/python/__init__.py
Expand Up @@ -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
184 changes: 177 additions & 7 deletions 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)

Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand All @@ -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)
148 changes: 148 additions & 0 deletions 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("<span data-autorefs-optional-hover={full_path}>{path}</span>").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"<span data-autorefs-optional-hover={{{path_var}}}>{{{path_var}}}</span>"

text = re.sub(r"([\w.]+)", repl, text)
if code:
text = f"<code>{text}</code>"
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

0 comments on commit bb6005e

Please sign in to comment.