.+?) *$", flags=re.MULTILINE)
+ """The regular expression to match our autodoc instructions."""
+
+ def __init__(
+ self,
+ md: Markdown,
+ *,
+ handlers: Handlers,
+ autorefs: AutorefsPlugin,
+ ) -> None:
+ """Initialize the object.
+
+ Arguments:
+ md: A `markdown.Markdown` instance.
+ handlers: The handlers container.
+ autorefs: The autorefs plugin instance.
+ """
+ super().__init__(parser=md.parser)
+ self.md = md
+ """The Markdown instance."""
+ self._handlers = handlers
+ self._autorefs = autorefs
+ self._updated_envs: set = set()
+
+ def test(self, parent: Element, block: str) -> bool: # noqa: ARG002
+ """Match our autodoc instructions.
+
+ Arguments:
+ parent: The parent element in the XML tree.
+ block: The block to be tested.
+
+ Returns:
+ Whether this block should be processed or not.
+ """
+ return bool(self.regex.search(block))
+
+ def run(self, parent: Element, blocks: MutableSequence[str]) -> None:
+ """Run code on the matched blocks.
+
+ The identifier and configuration lines are retrieved from a matched block
+ and used to collect and render an object.
+
+ Arguments:
+ parent: The parent element in the XML tree.
+ blocks: The rest of the blocks to be processed.
+ """
+ block = blocks.pop(0)
+ match = self.regex.search(block)
+
+ if match:
+ if match.start() > 0:
+ self.parser.parseBlocks(parent, [block[: match.start()]])
+ # removes the first line
+ block = block[match.end() :]
+
+ block, the_rest = self.detab(block)
+
+ if not block and blocks and blocks[0].startswith((" handler:", " options:")):
+ # YAML options were separated from the `:::` line by a blank line.
+ block = blocks.pop(0)
+
+ if match:
+ identifier = match["name"]
+ heading_level = match["heading"].count("#")
+ _logger.debug("Matched '::: %s'", identifier)
+
+ html, handler, _ = self._process_block(identifier, block, heading_level)
+ el = Element("div", {"class": "mkdocstrings"})
+ # The final HTML is inserted as opaque to subsequent processing, and only revealed at the end.
+ el.text = self.md.htmlStash.store(html)
+
+ if handler.outer_layer:
+ self._process_headings(handler, el)
+
+ parent.append(el)
+
+ if the_rest:
+ # This block contained unindented line(s) after the first indented
+ # line. Insert these lines as the first block of the master blocks
+ # list for future processing.
+ blocks.insert(0, the_rest)
+
+ def _process_block(
+ self,
+ identifier: str,
+ yaml_block: str,
+ heading_level: int = 0,
+ ) -> tuple[str, BaseHandler, CollectorItem]:
+ """Process an autodoc block.
+
+ Arguments:
+ identifier: The identifier of the object to collect and render.
+ yaml_block: The YAML configuration.
+ heading_level: Suggested level of the heading to insert (0 to ignore).
+
+ Raises:
+ PluginError: When something wrong happened during collection.
+ TemplateNotFound: When a template used for rendering could not be found.
+
+ Returns:
+ Rendered HTML, the handler that was used, and the collected item.
+ """
+ local_config = yaml.safe_load(yaml_block) or {}
+ handler_name = self._handlers.get_handler_name(local_config)
+
+ _logger.debug("Using handler '%s'", handler_name)
+ handler = self._handlers.get_handler(handler_name)
+
+ local_options = local_config.get("options", {})
+ if heading_level:
+ # Heading level obtained from Markdown (`##`) takes precedence.
+ local_options["heading_level"] = heading_level
+
+ options = handler.get_options(local_options)
+
+ _logger.debug("Collecting data")
+ try:
+ data: CollectorItem = handler.collect(identifier, options)
+ except CollectionError as exception:
+ _logger.error("%s", exception) # noqa: TRY400
+ raise PluginError(f"Could not collect '{identifier}'") from exception
+
+ if handler_name not in self._updated_envs: # We haven't seen this handler before on this document.
+ _logger.debug("Updating handler's rendering env")
+ handler._update_env(self.md, config=self._handlers._tool_config)
+ self._updated_envs.add(handler_name)
+
+ _logger.debug("Rendering templates")
+ if "locale" in signature(handler.render).parameters:
+ render = partial(handler.render, locale=self._handlers._locale)
+ else:
+ render = handler.render
+ try:
+ rendered = render(data, options)
+ except TemplateNotFound as exc:
+ _logger.error( # noqa: TRY400
+ "Template '%s' not found for '%s' handler and theme '%s'.",
+ exc.name,
+ handler_name,
+ self._handlers._theme,
+ )
+ raise
+
+ return rendered, handler, data
+
+ def _process_headings(self, handler: BaseHandler, element: Element) -> None:
+ # We're in the outer handler layer, as well as the outer extension layer.
+ #
+ # The "handler layer" tracks the nesting of the autodoc blocks, which can appear in docstrings.
+ #
+ # - Render ::: Object1 # Outer handler layer
+ # - Render Object1's docstring # Outer handler layer
+ # - Docstring renders ::: Object2 # Inner handler layers
+ # - etc. # Inner handler layers
+ #
+ # The "extension layer" tracks whether we're converting an autodoc instruction
+ # or nested content within it, like docstrings. Markdown conversion within Markdown conversion.
+ #
+ # - Render ::: Object1 # Outer extension layer
+ # - Render Object1's docstring # Inner extension layer
+ #
+ # The generated HTML was just stashed, and the `toc` extension won't be able to see headings.
+ # We need to duplicate the headings directly, just so `toc` can pick them up,
+ # otherwise they wouldn't appear in the final table of contents.
+ #
+ # These headings are generated by the `BaseHandler.do_heading` method (Jinja filter),
+ # which runs in the inner extension layer, and not in the outer one where we are now.
+ headings = handler.get_headings()
+ element.extend(headings)
+ # These duplicated headings will later be removed by our `_HeadingsPostProcessor` processor,
+ # which runs right after `toc` (see `MkdocstringsExtension.extendMarkdown`).
+ #
+ # If we were in an inner handler layer, we wouldn't do any of this
+ # and would just let headings bubble up to the outer handler layer.
+
+ if (page := self._autorefs.current_page) is None:
+ return
+
+ for heading in headings:
+ rendered_id = heading.attrib["id"]
+
+ skip_inventory = "data-skip-inventory" in heading.attrib
+ if skip_inventory:
+ _logger.debug(
+ "Skipping heading with id %r because data-skip-inventory is present",
+ rendered_id,
+ )
+ continue
+
+ # The title is registered to be used as tooltip by autorefs.
+ self._autorefs.register_anchor(page, rendered_id, title=heading.text, primary=True)
+
+ # Register all identifiers for this object
+ # both in the autorefs plugin and in the inventory.
+ aliases: tuple[str, ...]
+ aliases = handler.get_aliases(rendered_id)
+
+ for alias in aliases:
+ if alias != rendered_id:
+ self._autorefs.register_anchor(page, alias, rendered_id, primary=False)
+
+ if "data-role" in heading.attrib:
+ self._handlers.inventory.register(
+ name=rendered_id,
+ domain=handler.domain,
+ role=heading.attrib["data-role"],
+ priority=1, # Register with standard priority.
+ uri=f"{page.url}#{rendered_id}",
+ )
+ for alias in aliases:
+ if alias not in self._handlers.inventory:
+ self._handlers.inventory.register(
+ name=alias,
+ domain=handler.domain,
+ role=heading.attrib["data-role"],
+ priority=2, # Register with lower priority.
+ uri=f"{page.url}#{rendered_id}",
+ )
+
+
+class _HeadingsPostProcessor(Treeprocessor):
+ def run(self, root: Element) -> None:
+ self._remove_duplicated_headings(root)
+
+ def _remove_duplicated_headings(self, parent: Element) -> None:
+ carry_text = ""
+ for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration.
+ if el.tag == "div" and el.get("class") == "mkdocstrings":
+ # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML).
+ carry_text = (el.text or "") + carry_text
+ parent.remove(el)
+ else:
+ if carry_text:
+ el.tail = (el.tail or "") + carry_text
+ carry_text = ""
+ self._remove_duplicated_headings(el)
+
+ if carry_text:
+ parent.text = (parent.text or "") + carry_text
+
+
+class _TocLabelsTreeProcessor(Treeprocessor):
+ def run(self, root: Element) -> None: # noqa: ARG002
+ self._override_toc_labels(self.md.toc_tokens) # ty: ignore[unresolved-attribute]
+
+ def _override_toc_labels(self, tokens: list[dict[str, Any]]) -> None:
+ for token in tokens:
+ if (label := token.get("data-toc-label")) and token["name"] != label:
+ token["name"] = label
+ self._override_toc_labels(token["children"])
+
+
+class MkdocstringsExtension(Extension):
+ """Our Markdown extension.
+
+ It cannot work outside of `mkdocstrings`.
+ """
+
+ def __init__(
+ self,
+ handlers: Handlers,
+ autorefs: AutorefsPlugin,
+ *,
+ autorefs_extension: bool = False,
+ **kwargs: Any,
+ ) -> None:
+ """Initialize the object.
+
+ Arguments:
+ handlers: The handlers container.
+ autorefs: The autorefs plugin instance.
+ autorefs_extension: Whether the autorefs extension must be registered.
+ **kwargs: Keyword arguments used by `markdown.extensions.Extension`.
+ """
+ super().__init__(**kwargs)
+ self._handlers = handlers
+ self._autorefs = autorefs
+ self._autorefs_extension = autorefs_extension
+
+ def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
+ """Register the extension.
+
+ Add an instance of our [`AutoDocProcessor`][mkdocstrings.AutoDocProcessor] to the Markdown parser.
+
+ Arguments:
+ md: A `markdown.Markdown` instance.
+ """
+ md.registerExtension(self)
+
+ # Zensical integration: get the current page from the Zensical-specific preprocessor.
+ if "zensical_current_page" in md.preprocessors:
+ self._autorefs.current_page = md.preprocessors["zensical_current_page"]
+
+ md.parser.blockprocessors.register(
+ AutoDocProcessor(md, handlers=self._handlers, autorefs=self._autorefs),
+ "mkdocstrings",
+ priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor
+ )
+ md.treeprocessors.register(
+ _HeadingsPostProcessor(md),
+ "mkdocstrings_post_headings",
+ priority=4, # Right after 'toc'.
+ )
+ md.treeprocessors.register(
+ _TocLabelsTreeProcessor(md),
+ "mkdocstrings_post_toc_labels",
+ priority=4, # Right after 'toc'.
+ )
+
+ if self._autorefs_extension:
+ AutorefsExtension(self._autorefs).extendMarkdown(md)
+
+
+# -----------------------------------------------------------------------------
+# The following is only used by Zensical. The goal is to provide temporary
+# compatibility for users migrating from MkDocs (and Material for MkDocs)
+# to Zensical. When detecting the use of the mkdocstrings plugin in mkdocs.yml,
+# Zensical will add the mkdocstrings extension to its Markdown extensions.
+
+_default_config: dict[str, Any] = {
+ "default_handler": "python",
+ "handlers": {},
+ "custom_templates": None,
+ "locale": "en",
+ "enable_inventory": True,
+ "enabled": True,
+}
+
+
+def _split_configs(
+ markdown_extensions: list[str | dict[str, dict[str, Any]] | Extension],
+) -> tuple[list[str | Extension], dict[str, dict[str, Any]]]:
+ # Split markdown extensions and their configs from mkdocs.yml
+ mdx: list[str | Extension] = []
+ mdx_config: dict[str, dict[str, Any]] = {}
+ for item in markdown_extensions:
+ if isinstance(item, (str, Extension)):
+ mdx.append(item)
+ elif isinstance(item, dict):
+ for key, value in item.items():
+ mdx.append(key)
+ mdx_config[key] = value
+ break # Only one item per dict
+ return mdx, mdx_config
+
+
+class _ToolConfig:
+ def __init__(self, config_file_path: str | None = None) -> None:
+ self.config_file_path = config_file_path
+
+
+_AUTOREFS = None
+_HANDLERS = None
+
+
+def makeExtension( # noqa: N802
+ *,
+ default_handler: str | None = None,
+ inventory_project: str | None = None,
+ inventory_version: str | None = None,
+ handlers: dict[str, dict] | None = None,
+ custom_templates: str | None = None,
+ markdown_extensions: list[str | dict | Extension] | None = None,
+ locale: str | None = None,
+ config_file_path: str | None = None,
+) -> MkdocstringsExtension:
+ """Create the extension instance.
+
+ We only support this function being used by Zensical.
+ Consider this function private API.
+ """
+ global _AUTOREFS # noqa: PLW0603
+ if _AUTOREFS is None:
+ _AUTOREFS = AutorefsPlugin()
+ _AUTOREFS.config = AutorefsConfig() # ty:ignore[invalid-assignment]
+ _AUTOREFS.config.resolve_closest = True
+ _AUTOREFS.config.link_titles = "auto"
+ _AUTOREFS.config.strip_title_tags = "auto"
+ _AUTOREFS.scan_toc = True
+ _AUTOREFS._link_titles = "external"
+ _AUTOREFS._strip_title_tags = False
+
+ global _HANDLERS # noqa: PLW0603
+ if _HANDLERS is None:
+ mdx, mdx_config = _split_configs(markdown_extensions or [])
+ tool_config = _ToolConfig(config_file_path=config_file_path)
+ mdx.append(AutorefsExtension(_AUTOREFS))
+ _HANDLERS = Handlers(
+ theme="material",
+ default=default_handler or _default_config["default_handler"],
+ inventory_project=inventory_project or "Project",
+ inventory_version=inventory_version or "0.0.0",
+ handlers_config=handlers or _default_config["handlers"],
+ custom_templates=custom_templates or _default_config["custom_templates"],
+ mdx=mdx,
+ mdx_config=mdx_config,
+ locale=locale or _default_config["locale"],
+ tool_config=tool_config,
+ )
+
+ _HANDLERS._download_inventories()
+ register = _AUTOREFS.register_url
+ for identifier, url in _HANDLERS._yield_inventory_items():
+ register(identifier, url)
+
+ return MkdocstringsExtension(
+ handlers=_HANDLERS,
+ autorefs=_AUTOREFS,
+ autorefs_extension=True,
+ )
+
+
+def _reset() -> None:
+ global _AUTOREFS, _HANDLERS # noqa: PLW0603
+ _AUTOREFS = None
+ _HANDLERS = None
+
+
+def _get_autorefs() -> dict[str, Any]:
+ if _AUTOREFS:
+ return {
+ "primary": _AUTOREFS._primary_url_map,
+ "secondary": _AUTOREFS._secondary_url_map,
+ "inventory": _AUTOREFS._abs_url_map,
+ "titles": _AUTOREFS._title_map,
+ }
+ return {}
+
+
+def _get_inventory() -> bytes:
+ if _HANDLERS:
+ return _HANDLERS.inventory.format_sphinx()
+ return b""
diff --git a/src/mkdocstrings/_internal/handlers/__init__.py b/src/mkdocstrings/_internal/handlers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/mkdocstrings/handlers/base.py b/src/mkdocstrings/_internal/handlers/base.py
similarity index 52%
rename from src/mkdocstrings/handlers/base.py
rename to src/mkdocstrings/_internal/handlers/base.py
index d86e9df1..799c4499 100644
--- a/src/mkdocstrings/handlers/base.py
+++ b/src/mkdocstrings/_internal/handlers/base.py
@@ -1,38 +1,61 @@
-"""Base module for handlers.
-
-This module contains the base classes for implementing handlers.
-"""
+# Base module for handlers.
+#
+# This module contains the base classes for implementing handlers.
from __future__ import annotations
+import datetime
import importlib
-import sys
+import ssl
+from concurrent import futures
+from importlib.metadata import entry_points
+from io import BytesIO
from pathlib import Path
-from typing import Any, BinaryIO, ClassVar, Iterable, Iterator, Mapping, MutableMapping, Sequence, cast
+from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar, cast
+from warnings import warn
from xml.etree.ElementTree import Element, tostring
from jinja2 import Environment, FileSystemLoader
from markdown import Markdown
-from markdown.extensions.toc import TocTreeprocessor
from markupsafe import Markup
+from mkdocs.utils.cache import download_and_cache_url
+from mkdocs_autorefs import AutorefsInlineProcessor, BacklinksTreeProcessor
-from mkdocstrings.handlers.rendering import (
+from mkdocstrings._internal.download import _download_url_with_gz
+from mkdocstrings._internal.handlers.rendering import (
HeadingShiftingTreeprocessor,
Highlighter,
IdPrependingTreeprocessor,
MkdocstringsInnerExtension,
ParagraphStrippingTreeprocessor,
)
-from mkdocstrings.inventory import Inventory
-from mkdocstrings.loggers import get_template_logger
+from mkdocstrings._internal.inventory import Inventory
+from mkdocstrings._internal.loggers import get_logger, get_template_logger
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
-# TODO: remove once support for Python 3.9 is dropped
-if sys.version_info < (3, 10):
- from importlib_metadata import entry_points
-else:
- from importlib.metadata import entry_points
+ from markdown import Extension
+ from markdown.extensions.toc import TocTreeprocessor
+ from mkdocs_autorefs import AutorefsHookInterface, Backlink
+
+_logger = get_logger("mkdocstrings")
CollectorItem = Any
+"""The type of the item returned by the `collect` method of a handler."""
+HandlerConfig = Any
+"""The type of the configuration of a handler."""
+HandlerOptions = Any
+"""The type of the options passed to a handler."""
+
+
+# Autodoc instructions can appear in nested Markdown,
+# so we need to keep track of the Markdown conversion layer we're in.
+# Since any handler can be called from any Markdown conversion layer,
+# we need to keep track of the layer globally.
+# This global variable is incremented/decremented in `do_convert_markdown`,
+# and used in `outer_layer`.
+_markdown_conversion_layer: int = 0
class CollectionError(Exception):
@@ -74,49 +97,67 @@ class BaseHandler:
To add custom CSS, add an `extra_css` variable or create an 'style.css' file beside the templates.
"""
- # TODO: Make name mandatory?
- name: str = ""
+ name: ClassVar[str]
"""The handler's name, for example "python"."""
- domain: str = "default"
+
+ domain: ClassVar[str]
"""The handler's domain, used to register objects in the inventory, for example "py"."""
- enable_inventory: bool = False
+
+ enable_inventory: ClassVar[bool] = False
"""Whether the inventory creation is enabled."""
- fallback_config: ClassVar[dict] = {}
- """Fallback configuration when searching anchors for identifiers."""
- fallback_theme: str = ""
+
+ fallback_theme: ClassVar[str] = ""
"""Fallback theme to use when a template isn't found in the configured theme."""
- extra_css = ""
+
+ extra_css: str = ""
"""Extra CSS."""
- def __init__(self, handler: str, theme: str, custom_templates: str | None = None) -> None:
+ def __init__(
+ self,
+ *,
+ theme: str,
+ custom_templates: str | None,
+ mdx: Sequence[str | Extension],
+ mdx_config: Mapping[str, Any],
+ ) -> None:
"""Initialize the object.
If the given theme is not supported (it does not exist), it will look for a `fallback_theme` attribute
in `self` to use as a fallback theme.
- Arguments:
- handler: The name of the handler.
- theme: The name of theme to use.
- custom_templates: Directory containing custom templates.
+ Keyword Arguments:
+ theme (str): The theme to use.
+ custom_templates (str | None): The path to custom templates.
+ mdx (list[str | Extension]): A list of Markdown extensions to use.
+ mdx_config (Mapping[str, Mapping[str, Any]]): Configuration for the Markdown extensions.
"""
+ self.theme = theme
+ """The selected theme."""
+ self.custom_templates = custom_templates
+ """The path to custom templates."""
+ self.mdx = mdx
+ """The Markdown extensions to use."""
+ self.mdx_config = mdx_config
+ """The configuration for the Markdown extensions."""
+ self._md: Markdown | None = None
+ self._headings: list[Element] = []
+
paths = []
# add selected theme templates
- themes_dir = self.get_templates_dir(handler)
- paths.append(themes_dir / theme)
+ themes_dir = self.get_templates_dir(self.name)
+ paths.append(themes_dir / self.theme)
# add extended theme templates
- extended_templates_dirs = self.get_extended_templates_dirs(handler)
- for templates_dir in extended_templates_dirs:
- paths.append(templates_dir / theme)
+ extended_templates_dirs = self.get_extended_templates_dirs(self.name)
+ paths.extend(templates_dir / self.theme for templates_dir in extended_templates_dirs)
# add fallback theme templates
- if self.fallback_theme and self.fallback_theme != theme:
+ if self.fallback_theme and self.fallback_theme != self.theme:
paths.append(themes_dir / self.fallback_theme)
# add fallback theme of extended templates
- for templates_dir in extended_templates_dirs:
- paths.append(templates_dir / self.fallback_theme)
+ paths.extend(templates_dir / self.fallback_theme for templates_dir in extended_templates_dirs)
for path in paths:
css_path = path / "style.css"
@@ -124,19 +165,35 @@ def __init__(self, handler: str, theme: str, custom_templates: str | None = None
self.extra_css += "\n" + css_path.read_text(encoding="utf-8")
break
- if custom_templates is not None:
- paths.insert(0, Path(custom_templates) / handler / theme)
+ if self.custom_templates is not None:
+ paths.insert(0, Path(self.custom_templates) / self.name / self.theme)
self.env = Environment(
autoescape=True,
loader=FileSystemLoader(paths),
auto_reload=False, # Editing a template in the middle of a build is not useful.
)
+ """The Jinja environment."""
+
+ self.env.filters["convert_markdown"] = self.do_convert_markdown
+ self.env.filters["heading"] = self.do_heading
self.env.filters["any"] = do_any
- self.env.globals["log"] = get_template_logger(self.name)
+ self.env.globals["log"] = get_template_logger(self.name) # ty:ignore[invalid-assignment]
- self._headings: list[Element] = []
- self._md: Markdown = None # type: ignore[assignment] # To be populated in `update_env`.
+ @property
+ def md(self) -> Markdown:
+ """The Markdown instance.
+
+ Raises:
+ RuntimeError: When the Markdown instance is not set yet.
+ """
+ if self._md is None:
+ raise RuntimeError("Markdown instance not set yet")
+ return self._md
+
+ def get_inventory_urls(self) -> list[tuple[str, dict[str, Any]]]:
+ """Return the URLs (and configuration options) of the inventory files to download."""
+ return []
@classmethod
def load_inventory(
@@ -159,7 +216,22 @@ def load_inventory(
"""
yield from ()
- def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem:
+ def get_options(self, local_options: Mapping[str, Any]) -> HandlerOptions:
+ """Get combined options.
+
+ Override this method to customize how options are combined,
+ for example by merging the global options with the local options.
+ By combining options here, you don't have to do it twice in `collect` and `render`.
+
+ Arguments:
+ local_options: The local options.
+
+ Returns:
+ The combined options.
+ """
+ return local_options
+
+ def collect(self, identifier: str, options: HandlerOptions) -> CollectorItem:
"""Collect data given an identifier and user configuration.
In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into
@@ -167,27 +239,40 @@ def collect(self, identifier: str, config: MutableMapping[str, Any]) -> Collecto
Arguments:
identifier: An identifier for which to collect data. For example, in Python,
- it would be 'mkdocstrings.handlers' to collect documentation about the handlers module.
+ it would be 'mkdocstrings.BaseHandler' to collect documentation about the BaseHandler class.
It can be anything that you can feed to the tool of your choice.
- config: The handler's configuration options.
+ options: The final configuration options.
Returns:
Anything you want, as long as you can feed it to the handler's `render` method.
"""
raise NotImplementedError
- def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str:
+ def render(self, data: CollectorItem, options: HandlerOptions, *, locale: str | None = None) -> str:
"""Render a template using provided data and configuration options.
Arguments:
data: The collected data to render.
- config: The handler's configuration options.
+ options: The final configuration options.
+ locale: The locale to use for translations, if any.
Returns:
The rendered template as HTML.
"""
raise NotImplementedError
+ def render_backlinks(self, backlinks: Mapping[str, Iterable[Backlink]], *, locale: str | None = None) -> str: # noqa: ARG002
+ """Render backlinks.
+
+ Parameters:
+ backlinks: A mapping of identifiers to backlinks.
+ locale: The locale to use for translations, if any.
+
+ Returns:
+ The rendered backlinks as HTML.
+ """
+ return ""
+
def teardown(self) -> None:
"""Teardown the handler.
@@ -212,7 +297,7 @@ def get_templates_dir(self, handler: str | None = None) -> Path:
"""
handler = handler or self.name
try:
- import mkdocstrings_handlers
+ import mkdocstrings_handlers # noqa: PLC0415
except ModuleNotFoundError as error:
raise ModuleNotFoundError(f"Handler '{handler}' not found, is it installed?") from error
@@ -235,17 +320,22 @@ def get_extended_templates_dirs(self, handler: str) -> list[Path]:
discovered_extensions = entry_points(group=f"mkdocstrings.{handler}.templates")
return [extension.load()() for extension in discovered_extensions]
- def get_anchors(self, data: CollectorItem) -> tuple[str, ...]: # noqa: ARG002
- """Return the possible identifiers (HTML anchors) for a collected item.
+ def get_aliases(self, identifier: str) -> tuple[str, ...]: # noqa: ARG002
+ """Return the possible aliases for a given identifier.
Arguments:
- data: The collected data.
+ identifier: The identifier to get the aliases of.
Returns:
- The HTML anchors (without '#'), or an empty tuple if this item doesn't have an anchor.
+ A tuple of strings - aliases.
"""
return ()
+ @property
+ def outer_layer(self) -> bool:
+ """Whether we're in the outer Markdown conversion layer."""
+ return _markdown_conversion_layer == 0
+
def do_convert_markdown(
self,
text: str,
@@ -253,6 +343,7 @@ def do_convert_markdown(
html_id: str = "",
*,
strip_paragraph: bool = False,
+ autoref_hook: AutorefsHookInterface | None = None,
) -> Markup:
"""Render Markdown text; for use inside templates.
@@ -260,22 +351,34 @@ def do_convert_markdown(
text: The text to convert.
heading_level: The base heading level to start all Markdown headings from.
html_id: The HTML id of the element that's considered the parent of this element.
- strip_paragraph: Whether to exclude the tag from around the whole output.
+ strip_paragraph: Whether to exclude the `
` tag from around the whole output.
Returns:
An HTML string.
"""
- treeprocessors = self._md.treeprocessors
- treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level # type: ignore[attr-defined]
- treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--" # type: ignore[attr-defined]
- treeprocessors[ParagraphStrippingTreeprocessor.name].strip = strip_paragraph # type: ignore[attr-defined]
+ global _markdown_conversion_layer # noqa: PLW0603
+ _markdown_conversion_layer += 1
+ treeprocessors = self.md.treeprocessors
+ treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = heading_level
+ treeprocessors[IdPrependingTreeprocessor.name].id_prefix = html_id and html_id + "--"
+ treeprocessors[ParagraphStrippingTreeprocessor.name].strip = strip_paragraph
+ if BacklinksTreeProcessor.name in treeprocessors:
+ treeprocessors[BacklinksTreeProcessor.name].initial_id = html_id
+ if autoref_hook and AutorefsInlineProcessor.name in self.md.inlinePatterns:
+ self.md.inlinePatterns[AutorefsInlineProcessor.name].hook = autoref_hook # ty: ignore[unresolved-attribute]
+
try:
- return Markup(self._md.convert(text))
+ return Markup(self.md.convert(text))
finally:
- treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0 # type: ignore[attr-defined]
- treeprocessors[IdPrependingTreeprocessor.name].id_prefix = "" # type: ignore[attr-defined]
- treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False # type: ignore[attr-defined]
- self._md.reset()
+ treeprocessors[HeadingShiftingTreeprocessor.name].shift_by = 0
+ treeprocessors[IdPrependingTreeprocessor.name].id_prefix = ""
+ treeprocessors[ParagraphStrippingTreeprocessor.name].strip = False
+ if BacklinksTreeProcessor.name in treeprocessors:
+ treeprocessors[BacklinksTreeProcessor.name].initial_id = None
+ if AutorefsInlineProcessor.name in self.md.inlinePatterns:
+ self.md.inlinePatterns[AutorefsInlineProcessor.name].hook = None
+ self.md.reset()
+ _markdown_conversion_layer -= 1
def do_heading(
self,
@@ -285,6 +388,7 @@ def do_heading(
role: str | None = None,
hidden: bool = False,
toc_label: str | None = None,
+ skip_inventory: bool = False,
**attributes: str,
) -> Markup:
"""Render an HTML heading and register it for the table of contents. For use inside templates.
@@ -295,6 +399,7 @@ def do_heading(
role: An optional role for the object bound to this heading.
hidden: If True, only register it for the table of contents, don't render anything.
toc_label: The title to use in the table of contents ('data-toc-label' attribute).
+ skip_inventory: Flag element to not be registered in the inventory (by setting a `data-skip-inventory` attribute).
**attributes: Any extra HTML attributes of the heading.
Returns:
@@ -314,8 +419,12 @@ def do_heading(
if toc_label is None:
toc_label = content.unescape() if isinstance(content, Markup) else content
el.set("data-toc-label", toc_label)
+ if skip_inventory:
+ el.set("data-skip-inventory", "true")
if role:
el.set("data-role", role)
+ if content:
+ el.text = str(content).strip()
self._headings.append(el)
if hidden:
@@ -326,7 +435,7 @@ def do_heading(
el = Element(f"h{heading_level}", attributes)
el.append(Element("mkdocstrings-placeholder"))
# Tell the inner 'toc' extension to make its additions if configured so.
- toc = cast(TocTreeprocessor, self._md.treeprocessors["toc"])
+ toc = cast("TocTreeprocessor", self.md.treeprocessors["toc"])
if toc.use_anchors:
toc.add_anchor(el, attributes["id"])
if toc.use_permalinks:
@@ -352,67 +461,81 @@ def get_headings(self) -> Sequence[Element]:
self._headings.clear()
return result
- def update_env(self, md: Markdown, config: dict) -> None: # noqa: ARG002
- """Update the Jinja environment.
+ def update_env(self, config: Any) -> None:
+ """Update the Jinja environment."""
- Arguments:
- md: The Markdown instance. Useful to add functions able to convert Markdown into the environment filters.
- config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code
- of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary.
- """
- self._md = md
- self.env.filters["highlight"] = Highlighter(md).highlight
- self.env.filters["convert_markdown"] = self.do_convert_markdown
- self.env.filters["heading"] = self.do_heading
-
- def _update_env(self, md: Markdown, config: dict) -> None:
+ def _update_env(self, md: Markdown, *, config: Any | None = None) -> None:
"""Update our handler to point to our configured Markdown instance, grabbing some of the config from `md`."""
- extensions = config["mdx"] + [MkdocstringsInnerExtension(self._headings)]
+ extensions: list[str | Extension] = [*self.mdx, MkdocstringsInnerExtension(self._headings)]
+
+ new_md = Markdown(extensions=extensions, extension_configs=self.mdx_config)
- new_md = Markdown(extensions=extensions, extension_configs=config["mdx_configs"])
# MkDocs adds its own (required) extension that's not part of the config. Propagate it.
if "relpath" in md.treeprocessors:
- new_md.treeprocessors.register(md.treeprocessors["relpath"], "relpath", priority=0)
+ relpath = md.treeprocessors["relpath"]
+ new_relpath = type(relpath)(relpath.file, relpath.files, relpath.config)
+ new_md.treeprocessors.register(new_relpath, "relpath", priority=0)
+ elif "zrelpath" in md.treeprocessors:
+ zrelpath = md.treeprocessors["zrelpath"]
+ new_zrelpath = type(zrelpath)(new_md, zrelpath.path, zrelpath.use_directory_urls)
+ new_md.treeprocessors.register(new_zrelpath, "zrelpath", priority=0)
- self.update_env(new_md, config)
+ self._md = new_md
+
+ self.env.filters["highlight"] = Highlighter(new_md).highlight
+
+ self.update_env(config)
class Handlers:
"""A collection of handlers.
- Do not instantiate this directly. [The plugin][mkdocstrings.plugin.MkdocstringsPlugin] will keep one instance of
- this for the purpose of caching. Use [mkdocstrings.plugin.MkdocstringsPlugin.get_handler][] for convenient access.
+ Do not instantiate this directly. [The plugin][mkdocstrings.MkdocstringsPlugin] will keep one instance of
+ this for the purpose of caching. Use [mkdocstrings.MkdocstringsPlugin.get_handler][] for convenient access.
"""
- def __init__(self, config: dict) -> None:
+ def __init__(
+ self,
+ *,
+ theme: str,
+ default: str,
+ inventory_project: str,
+ inventory_version: str = "0.0.0",
+ handlers_config: dict[str, HandlerConfig] | None = None,
+ custom_templates: str | None = None,
+ mdx: Sequence[str | Extension] | None = None,
+ mdx_config: Mapping[str, Any] | None = None,
+ locale: str = "en",
+ tool_config: Any,
+ ) -> None:
"""Initialize the object.
Arguments:
- config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code
- of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary.
+ theme: The theme to use.
+ default: The default handler to use.
+ inventory_project: The project name to use in the inventory.
+ inventory_version: The project version to use in the inventory.
+ handlers_config: The handlers configuration.
+ custom_templates: The path to custom templates.
+ mdx: A list of Markdown extensions to use.
+ mdx_config: Configuration for the Markdown extensions.
+ locale: The locale to use for translations.
+ tool_config: Tool configuration to pass down to handlers.
"""
- self._config = config
+ self._theme = theme
+ self._default = default
+ self._handlers_config = handlers_config or {}
+ self._custom_templates = custom_templates
+ self._mdx = mdx or []
+ self._mdx_config = mdx_config or {}
self._handlers: dict[str, BaseHandler] = {}
- self.inventory: Inventory = Inventory(project=self._config["mkdocs"]["site_name"])
+ self._locale = locale
+ self._tool_config = tool_config
- def get_anchors(self, identifier: str) -> tuple[str, ...]:
- """Return the canonical HTML anchor for the identifier, if any of the seen handlers can collect it.
+ self.inventory: Inventory = Inventory(project=inventory_project, version=inventory_version)
+ """The objects inventory."""
- Arguments:
- identifier: The identifier (one that [collect][mkdocstrings.handlers.base.BaseHandler.collect] can accept).
-
- Returns:
- A tuple of strings - anchors without '#', or an empty tuple if there isn't any identifier familiar with it.
- """
- for handler in self._handlers.values():
- fallback_config = getattr(handler, "fallback_config", {})
- try:
- anchors = handler.get_anchors(handler.collect(identifier, fallback_config))
- except CollectionError:
- continue
- if anchors:
- return anchors
- return ()
+ self._inv_futures: dict[futures.Future, tuple[BaseHandler, str, Any]] = {}
def get_handler_name(self, config: dict) -> str:
"""Return the handler name defined in an "autodoc" instruction YAML configuration, or the global default handler.
@@ -423,10 +546,7 @@ def get_handler_name(self, config: dict) -> str:
Returns:
The name of the handler to use.
"""
- global_config = self._config["mkdocstrings"]
- if "handler" in config:
- return config["handler"]
- return global_config["default_handler"]
+ return config.get("handler", self._default)
def get_handler_config(self, name: str) -> dict:
"""Return the global configuration of the given handler.
@@ -437,15 +557,12 @@ def get_handler_config(self, name: str) -> dict:
Returns:
The global configuration of the given handler. It can be an empty dictionary.
"""
- handlers = self._config["mkdocstrings"].get("handlers", {})
- if handlers:
- return handlers.get(name, {})
- return {}
+ return self._handlers_config.get(name, None) or {}
def get_handler(self, name: str, handler_config: dict | None = None) -> BaseHandler:
"""Get a handler thanks to its name.
- This function dynamically imports a module named "mkdocstrings.handlers.NAME", calls its
+ This function dynamically imports a module named "mkdocstrings_handlers.NAME", calls its
`get_handler` method to get an instance of a handler, and caches it in dictionary.
It means that during one run (for each reload when serving, or once when building),
a handler is instantiated only once, and reused for each "autodoc" instruction asking for it.
@@ -455,34 +572,95 @@ def get_handler(self, name: str, handler_config: dict | None = None) -> BaseHand
handler_config: Configuration passed to the handler.
Returns:
- An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler],
+ An instance of a subclass of [`BaseHandler`][mkdocstrings.BaseHandler],
as instantiated by the `get_handler` method of the handler's module.
"""
if name not in self._handlers:
if handler_config is None:
- handler_config = self.get_handler_config(name)
- handler_config.update(self._config)
+ handler_config = self._handlers_config.get(name, {})
module = importlib.import_module(f"mkdocstrings_handlers.{name}")
+
self._handlers[name] = module.get_handler(
- theme=self._config["theme_name"],
- custom_templates=self._config["mkdocstrings"]["custom_templates"],
- config_file_path=self._config["mkdocs"]["config_file_path"],
- **handler_config,
+ theme=self._theme,
+ custom_templates=self._custom_templates,
+ mdx=self._mdx,
+ mdx_config=self._mdx_config,
+ handler_config=handler_config,
+ tool_config=self._tool_config,
)
return self._handlers[name]
+ def _download_inventories(self) -> None:
+ """Download an inventory file from an URL.
+
+ Arguments:
+ url: The URL of the inventory.
+ """
+ to_download: list[tuple[BaseHandler, str, Any]] = []
+
+ for handler_name, conf in self._handlers_config.items():
+ handler = self.get_handler(handler_name)
+
+ if handler.get_inventory_urls.__func__ is BaseHandler.get_inventory_urls:
+ if inv_configs := conf.pop("import", ()):
+ warn(
+ "mkdocstrings v1 will stop handling 'import' in handlers configuration. "
+ "Instead your handler must define a `get_inventory_urls` method "
+ "that returns a list of URLs to download. ",
+ DeprecationWarning,
+ stacklevel=1,
+ )
+ inv_configs = [{"url": inv} if isinstance(inv, str) else inv for inv in inv_configs]
+ inv_configs = [(inv.pop("url"), inv) for inv in inv_configs]
+ else:
+ inv_configs = handler.get_inventory_urls()
+
+ to_download.extend((handler, url, conf) for url, conf in inv_configs)
+
+ if to_download:
+ # YORE: EOL 3.12: Remove block.
+ # NOTE: Create context in main thread to fix issue
+ # https://github.com/mkdocstrings/mkdocstrings/issues/796.
+ _ = ssl.create_default_context()
+
+ thread_pool = futures.ThreadPoolExecutor(4)
+ for handler, url, conf in to_download:
+ _logger.debug("Downloading inventory from %s", url)
+ future = thread_pool.submit(
+ download_and_cache_url,
+ url,
+ datetime.timedelta(days=1),
+ download=_download_url_with_gz,
+ )
+ self._inv_futures[future] = (handler, url, conf)
+ thread_pool.shutdown(wait=False)
+
+ def _yield_inventory_items(self) -> Iterator[tuple[str, str]]:
+ if self._inv_futures:
+ _logger.debug("Waiting for %s inventory download(s)", len(self._inv_futures))
+ futures.wait(self._inv_futures, timeout=30)
+ # Reversed order so that pages from first futures take precedence:
+ for fut, (handler, url, conf) in reversed(self._inv_futures.items()):
+ try:
+ yield from handler.load_inventory(BytesIO(fut.result()), url, **conf)
+ except Exception as error: # noqa: BLE001,PERF203
+ _logger.error("Couldn't load inventory %s through handler '%s': %s", url, handler.name, error) # noqa: TRY400
+ self._inv_futures = {}
+
@property
def seen_handlers(self) -> Iterable[BaseHandler]:
"""Get the handlers that were encountered so far throughout the build.
Returns:
- An iterable of instances of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler]
+ An iterable of instances of [`BaseHandler`][mkdocstrings.BaseHandler]
(usable only to loop through it).
"""
return self._handlers.values()
def teardown(self) -> None:
"""Teardown all cached handlers and clear the cache."""
+ for future in self._inv_futures:
+ future.cancel()
for handler in self.seen_handlers:
handler.teardown()
self._handlers.clear()
diff --git a/src/mkdocstrings/handlers/rendering.py b/src/mkdocstrings/_internal/handlers/rendering.py
similarity index 87%
rename from src/mkdocstrings/handlers/rendering.py
rename to src/mkdocstrings/_internal/handlers/rendering.py
index 1db3c8f1..264a77ef 100644
--- a/src/mkdocstrings/handlers/rendering.py
+++ b/src/mkdocstrings/_internal/handlers/rendering.py
@@ -1,4 +1,4 @@
-"""This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers."""
+# This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers.
from __future__ import annotations
@@ -84,7 +84,7 @@ def __init__(self, md: Markdown):
self._css_class = config.pop("css_class", "highlight")
super().__init__(**{name: opt for name, opt in config.items() if name in self._highlight_config_keys})
- def highlight(
+ def highlight( # ty: ignore[invalid-method-override]
self,
src: str,
language: str | None = None,
@@ -113,7 +113,7 @@ def highlight(
src = textwrap.dedent(src)
kwargs.setdefault("css_class", self._css_class)
- old_linenums = self.linenums # type: ignore[has-type]
+ old_linenums = self.linenums
if linenums is not None:
self.linenums = linenums
try:
@@ -133,7 +133,8 @@ def highlight(
class IdPrependingTreeprocessor(Treeprocessor):
"""Prepend the configured prefix to IDs of all HTML elements."""
- name = "mkdocstrings_ids"
+ name: str = "mkdocstrings_ids"
+ """The name of the treeprocessor."""
id_prefix: str
"""The prefix to add to every ID. It is prepended without any separator; specify your own separator if needed."""
@@ -148,7 +149,8 @@ def __init__(self, md: Markdown, id_prefix: str):
super().__init__(md)
self.id_prefix = id_prefix
- def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
+ def run(self, root: Element) -> None:
+ """Prepend the configured prefix to all IDs in the document."""
if self.id_prefix:
self._prefix_ids(root)
@@ -189,8 +191,11 @@ def _prefix_ids(self, root: Element) -> None:
class HeadingShiftingTreeprocessor(Treeprocessor):
"""Shift levels of all Markdown headings according to the configured base level."""
- name = "mkdocstrings_headings"
- regex = re.compile(r"([Hh])([1-6])")
+ name: str = "mkdocstrings_headings"
+ """The name of the treeprocessor."""
+
+ regex: re.Pattern = re.compile(r"([Hh])([1-6])")
+ """The regex to match heading tags."""
shift_by: int
"""The number of heading "levels" to add to every heading. `
` with `shift_by = 3` becomes ``."""
@@ -205,7 +210,8 @@ def __init__(self, md: Markdown, shift_by: int):
super().__init__(md)
self.shift_by = shift_by
- def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
+ def run(self, root: Element) -> None:
+ """Shift the levels of all headings in the document."""
if not self.shift_by:
return
for el in root.iter():
@@ -219,8 +225,11 @@ def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
class _HeadingReportingTreeprocessor(Treeprocessor):
"""Records the heading elements encountered in the document."""
- name = "mkdocstrings_headings_list"
- regex = re.compile(r"[Hh][1-6]")
+ name: str = "mkdocstrings_headings_list"
+ """The name of the treeprocessor."""
+
+ regex: re.Pattern = re.compile(r"[Hh][1-6]")
+ """The regex to match heading tags."""
headings: list[Element]
"""The list (the one passed in the initializer) that is used to record the heading elements (by appending to it)."""
@@ -230,7 +239,8 @@ def __init__(self, md: Markdown, headings: list[Element]):
self.headings = headings
def run(self, root: Element) -> None:
- permalink_class = self.md.treeprocessors["toc"].permalink_class # type: ignore[attr-defined]
+ """Record all heading elements encountered in the document."""
+ permalink_class = self.md.treeprocessors["toc"].permalink_class
for el in root.iter():
if self.regex.fullmatch(el.tag):
el = copy.copy(el) # noqa: PLW2901
@@ -242,14 +252,18 @@ def run(self, root: Element) -> None:
class ParagraphStrippingTreeprocessor(Treeprocessor):
- """Unwraps the
element around the whole output."""
+ """Unwraps the `
` element around the whole output."""
+
+ name: str = "mkdocstrings_strip_paragraph"
+ """The name of the treeprocessor."""
- name = "mkdocstrings_strip_paragraph"
- strip = False
+ strip: bool = False
+ """Whether to strip `
` elements or not."""
- def run(self, root: Element) -> Element | None: # noqa: D102 (ignore missing docstring)
+ def run(self, root: Element) -> Element | None:
+ """Unwrap the root element if it's a single `
` element."""
if self.strip and len(root) == 1 and root[0].tag == "p":
- # Turn the single
element into the root element and inherit its tag name (it's significant!)
+ # Turn the single `
` element into the root element and inherit its tag name (it's significant!)
root[0].tag = root.tag
return root[0]
return None
@@ -266,6 +280,7 @@ def __init__(self, headings: list[Element]):
"""
super().__init__()
self.headings = headings
+ """The list that will be populated with all HTML heading elements encountered in the document."""
def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
"""Register the extension.
diff --git a/src/mkdocstrings/inventory.py b/src/mkdocstrings/_internal/inventory.py
similarity index 81%
rename from src/mkdocstrings/inventory.py
rename to src/mkdocstrings/_internal/inventory.py
index f1c8962a..241bbb12 100644
--- a/src/mkdocstrings/inventory.py
+++ b/src/mkdocstrings/_internal/inventory.py
@@ -1,14 +1,17 @@
-"""Module responsible for the objects inventory."""
-
+# Module responsible for the objects inventory.
+#
# Credits to Brian Skinn and the sphobjinv project:
-# https://github.com/bskinn/sphobjinv
+# https://github.com/bskinn/sphobjinv.
from __future__ import annotations
import re
import zlib
from textwrap import dedent
-from typing import BinaryIO, Collection
+from typing import TYPE_CHECKING, BinaryIO, Literal, overload
+
+if TYPE_CHECKING:
+ from collections.abc import Collection
class InventoryItem:
@@ -34,11 +37,17 @@ def __init__(
dispname: The item display name.
"""
self.name: str = name
+ """The item name."""
self.domain: str = domain
+ """The item domain."""
self.role: str = role
+ """The item role."""
self.uri: str = uri
+ """The item URI."""
self.priority: int = priority
+ """The item priority."""
self.dispname: str = dispname or name
+ """The item display name."""
def format_sphinx(self) -> str:
"""Format this item as a Sphinx inventory line.
@@ -55,12 +64,23 @@ def format_sphinx(self) -> str:
return f"{self.name} {self.domain}:{self.role} {self.priority} {uri} {dispname}"
sphinx_item_regex = re.compile(r"^(.+?)\s+(\S+):(\S+)\s+(-?\d+)\s+(\S+)\s*(.*)$")
+ """Regex to parse a Sphinx v2 inventory line."""
+
+ @overload
+ @classmethod
+ def parse_sphinx(cls, line: str, *, return_none: Literal[False]) -> InventoryItem: ...
+ @overload
@classmethod
- def parse_sphinx(cls, line: str) -> InventoryItem:
+ def parse_sphinx(cls, line: str, *, return_none: Literal[True]) -> InventoryItem | None: ...
+
+ @classmethod
+ def parse_sphinx(cls, line: str, *, return_none: bool = False) -> InventoryItem | None:
"""Parse a line from a Sphinx v2 inventory file and return an `InventoryItem` from it."""
match = cls.sphinx_item_regex.search(line)
if not match:
+ if return_none:
+ return None
raise ValueError(line)
name, domain, role, priority, uri, dispname = match.groups()
if uri.endswith("$"):
@@ -86,7 +106,9 @@ def __init__(self, items: list[InventoryItem] | None = None, project: str = "pro
for item in items:
self[item.name] = item
self.project = project
+ """The project name."""
self.version = version
+ """The project version."""
def register(
self,
@@ -155,7 +177,9 @@ def parse_sphinx(cls, in_file: BinaryIO, *, domain_filter: Collection[str] = ())
for _ in range(4):
in_file.readline()
lines = zlib.decompress(in_file.read()).splitlines()
- items = [InventoryItem.parse_sphinx(line.decode("utf8")) for line in lines]
+ items: list[InventoryItem] = [
+ item for line in lines if (item := InventoryItem.parse_sphinx(line.decode("utf8"), return_none=True))
+ ]
if domain_filter:
items = [item for item in items if item.domain in domain_filter]
return cls(items)
diff --git a/src/mkdocstrings/loggers.py b/src/mkdocstrings/_internal/loggers.py
similarity index 80%
rename from src/mkdocstrings/loggers.py
rename to src/mkdocstrings/_internal/loggers.py
index 240e1808..6c8817ac 100644
--- a/src/mkdocstrings/loggers.py
+++ b/src/mkdocstrings/_internal/loggers.py
@@ -1,16 +1,19 @@
-"""Logging functions."""
+# Logging functions.
from __future__ import annotations
import logging
from contextlib import suppress
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Callable, MutableMapping, Sequence
+from typing import TYPE_CHECKING, Any
+
+from jinja2 import pass_context
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, MutableMapping, Sequence
+
+ from jinja2.runtime import Context
-try:
- from jinja2 import pass_context
-except ImportError: # TODO: remove once Jinja2 < 3.1 is dropped
- from jinja2 import contextfunction as pass_context # type: ignore[attr-defined,no-redef]
try:
import mkdocstrings_handlers
@@ -18,10 +21,7 @@
TEMPLATES_DIRS: Sequence[Path] = ()
else:
TEMPLATES_DIRS = tuple(mkdocstrings_handlers.__path__)
-
-
-if TYPE_CHECKING:
- from jinja2.runtime import Context
+ """The directories where the handler templates are located."""
class LoggerAdapter(logging.LoggerAdapter):
@@ -54,6 +54,7 @@ def __init__(self, prefix: str, logger: logging.Logger):
"""
super().__init__(logger, {})
self.prefix = prefix
+ """The prefix to insert in front of every message."""
self._logged: set[tuple[LoggerAdapter, str]] = set()
def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any]:
@@ -81,7 +82,7 @@ def log(self, level: int, msg: object, *args: object, **kwargs: object) -> None:
if (key := (self, str(msg))) in self._logged:
return
self._logged.add(key)
- super().log(level, msg, *args, **kwargs) # type: ignore[arg-type]
+ super().log(level, msg, *args, **kwargs) # ty: ignore[invalid-argument-type]
class TemplateLogger:
@@ -108,10 +109,36 @@ def __init__(self, logger: LoggerAdapter):
logger: A logger adapter.
"""
self.debug = get_template_logger_function(logger.debug)
+ """Log a DEBUG message."""
self.info = get_template_logger_function(logger.info)
+ """Log an INFO message."""
self.warning = get_template_logger_function(logger.warning)
+ """Log a WARNING message."""
self.error = get_template_logger_function(logger.error)
+ """Log an ERROR message."""
self.critical = get_template_logger_function(logger.critical)
+ """Log a CRITICAL message."""
+
+
+class _Lazy:
+ unset = object()
+
+ def __init__(self, func: Callable, *args: Any, **kwargs: Any):
+ self.func = func
+ self.args = args
+ self.kwargs = kwargs
+ self.result = self.unset
+
+ def __call__(self):
+ if self.result is self.unset:
+ self.result = self.func(*self.args, **self.kwargs)
+ return self.result
+
+ def __str__(self) -> str:
+ return str(self())
+
+ def __repr__(self) -> str:
+ return repr(self())
def get_template_logger_function(logger_func: Callable) -> Callable:
@@ -125,7 +152,7 @@ def get_template_logger_function(logger_func: Callable) -> Callable:
"""
@pass_context
- def wrapper(context: Context, msg: str | None = None, **kwargs: Any) -> str:
+ def wrapper(context: Context, msg: str | None = None, *args: Any, **kwargs: Any) -> str:
"""Log a message.
Arguments:
@@ -136,8 +163,7 @@ def wrapper(context: Context, msg: str | None = None, **kwargs: Any) -> str:
Returns:
An empty string.
"""
- template_path = get_template_path(context)
- logger_func(f"{template_path}: {msg or 'Rendering'}", **kwargs)
+ logger_func(f"%s: {msg or 'Rendering'}", _Lazy(get_template_path, context), *args, **kwargs)
return ""
return wrapper
diff --git a/src/mkdocstrings/_internal/plugin.py b/src/mkdocstrings/_internal/plugin.py
new file mode 100644
index 00000000..4f9bd29d
--- /dev/null
+++ b/src/mkdocstrings/_internal/plugin.py
@@ -0,0 +1,298 @@
+# This module contains the "mkdocstrings" plugin for MkDocs.
+#
+# The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.MkdocstringsExtension]),
+# and adds it to the list of Markdown extensions used by `mkdocs`
+# during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config).
+#
+# Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build)
+# is triggered and calls the [`handlers.teardown()` method][mkdocstrings.Handlers.teardown]. This method is
+# used to teardown the handlers that were instantiated during documentation buildup.
+#
+# Finally, when serving the documentation, it can add directories to watch
+# during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve).
+
+from __future__ import annotations
+
+import os
+import re
+from functools import partial
+from inspect import signature
+from re import Match
+from typing import TYPE_CHECKING, Any
+
+from mkdocs.config import Config
+from mkdocs.config import config_options as opt
+from mkdocs.plugins import BasePlugin, CombinedEvent, event_priority
+from mkdocs.utils import write_file
+from mkdocs_autorefs import AutorefsConfig, AutorefsPlugin
+
+from mkdocstrings._internal.extension import MkdocstringsExtension
+from mkdocstrings._internal.handlers.base import BaseHandler, Handlers
+from mkdocstrings._internal.loggers import get_logger
+
+if TYPE_CHECKING:
+ from jinja2.environment import Environment
+ from mkdocs.config.defaults import MkDocsConfig
+ from mkdocs.structure.files import Files
+
+
+_logger = get_logger("mkdocstrings")
+
+
+class PluginConfig(Config):
+ """The configuration options of `mkdocstrings`, written in `mkdocs.yml`."""
+
+ handlers = opt.Type(dict, default={})
+ """
+ Global configuration of handlers.
+
+ You can set global configuration per handler, applied everywhere,
+ but overridable in each "autodoc" instruction. Example:
+
+ ```yaml
+ plugins:
+ - mkdocstrings:
+ handlers:
+ python:
+ options:
+ option1: true
+ option2: "value"
+ rust:
+ options:
+ option9: 2
+ ```
+ """
+
+ default_handler = opt.Type(str, default="python")
+ """The default handler to use. The value is the name of the handler module. Default is "python"."""
+ custom_templates = opt.Optional(opt.Dir(exists=True))
+ """Location of custom templates to use when rendering API objects.
+
+ Value should be the path of a directory relative to the MkDocs configuration file.
+ """
+ enable_inventory = opt.Optional(opt.Type(bool))
+ """Whether to enable object inventory creation."""
+ enabled = opt.Type(bool, default=True)
+ """Whether to enable the plugin. Default is true. If false, *mkdocstrings* will not collect or render anything."""
+ locale = opt.Optional(opt.Type(str))
+ """The locale to use for translations."""
+
+
+class MkdocstringsPlugin(BasePlugin[PluginConfig]):
+ """An `mkdocs` plugin.
+
+ This plugin defines the following event hooks:
+
+ - `on_config`
+ - `on_env`
+ - `on_post_build`
+
+ Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
+ for more information about its plugin system.
+ """
+
+ css_filename: str = "assets/_mkdocstrings.css"
+ """The path of the CSS file to write in the site directory."""
+
+ def __init__(self) -> None:
+ """Initialize the object."""
+ super().__init__()
+ self._handlers: Handlers | None = None
+
+ @property
+ def handlers(self) -> Handlers:
+ """Get the instance of [mkdocstrings.Handlers][] for this plugin/build.
+
+ Raises:
+ RuntimeError: If the plugin hasn't been initialized with a config.
+
+ Returns:
+ An instance of [mkdocstrings.Handlers][] (the same throughout the build).
+ """
+ if not self._handlers:
+ raise RuntimeError("The plugin hasn't been initialized with a config yet")
+ return self._handlers
+
+ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
+ """Instantiate our Markdown extension.
+
+ Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
+ In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.MkdocstringsExtension]
+ and add it to the list of Markdown extensions used by `mkdocs`.
+
+ We pass this plugin's configuration dictionary to the extension when instantiating it (it will need it
+ later when processing markdown to get handlers and their global configurations).
+
+ Arguments:
+ config: The MkDocs config object.
+
+ Returns:
+ The modified config.
+ """
+ if not self.plugin_enabled:
+ _logger.debug("Plugin is not enabled. Skipping.")
+ return config
+ _logger.debug("Adding extension to the list")
+
+ locale = self.config.locale or config.theme.get("language") or config.theme.get("locale") or "en"
+ locale = str(locale).replace("_", "-")
+
+ handlers = Handlers(
+ default=self.config.default_handler,
+ handlers_config=self.config.handlers,
+ theme=config.theme.name or os.path.dirname(config.theme.dirs[0]), # noqa: PTH120
+ custom_templates=self.config.custom_templates,
+ mdx=config.markdown_extensions,
+ mdx_config=config.mdx_configs,
+ inventory_project=config.site_name,
+ inventory_version="0.0.0", # TODO: Find a way to get actual version.
+ locale=locale,
+ tool_config=config,
+ )
+
+ handlers._download_inventories()
+
+ AutorefsPlugin.record_backlinks = True
+ autorefs: AutorefsPlugin
+ try:
+ # If autorefs plugin is explicitly enabled, just use it.
+ autorefs = config.plugins["autorefs"] # ty: ignore[invalid-assignment]
+ _logger.debug("Picked up existing autorefs instance %r", autorefs)
+ except KeyError:
+ # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`.
+ autorefs = AutorefsPlugin()
+ autorefs.config = AutorefsConfig() # ty:ignore[invalid-assignment]
+ autorefs.scan_toc = False
+ config.plugins["autorefs"] = autorefs
+ _logger.debug("Added a subdued autorefs instance %r", autorefs)
+
+ mkdocstrings_extension = MkdocstringsExtension(handlers, autorefs)
+ config.markdown_extensions.append(mkdocstrings_extension) # ty: ignore[invalid-argument-type]
+
+ config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files.
+
+ self._autorefs = autorefs
+ self._handlers = handlers
+ return config
+
+ @property
+ def inventory_enabled(self) -> bool:
+ """Tell if the inventory is enabled or not.
+
+ Returns:
+ Whether the inventory is enabled.
+ """
+ inventory_enabled = self.config.enable_inventory
+ if inventory_enabled is None:
+ inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers)
+ return inventory_enabled
+
+ @property
+ def plugin_enabled(self) -> bool:
+ """Tell if the plugin is enabled or not.
+
+ Returns:
+ Whether the plugin is enabled.
+ """
+ return self.config.enabled
+
+ @event_priority(50) # Early, before autorefs' starts applying cross-refs and collecting backlinks.
+ def _on_env_load_inventories(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
+ if self.plugin_enabled and self._handlers:
+ register = config.plugins["autorefs"].register_url # ty: ignore[unresolved-attribute]
+ for identifier, url in self._handlers._yield_inventory_items():
+ register(identifier, url)
+
+ @event_priority(-20) # Late, not important.
+ def _on_env_add_css(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
+ if self.plugin_enabled and self._handlers:
+ css_content = "\n".join(handler.extra_css for handler in self.handlers.seen_handlers)
+ write_file(css_content.encode("utf-8"), os.path.join(config.site_dir, self.css_filename)) # noqa: PTH118
+
+ @event_priority(-20) # Late, not important.
+ def _on_env_write_inventory(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
+ if self.plugin_enabled and self._handlers and self.inventory_enabled:
+ _logger.debug("Creating inventory file objects.inv")
+ inv_contents = self.handlers.inventory.format_sphinx()
+ write_file(inv_contents, os.path.join(config.site_dir, "objects.inv")) # noqa: PTH118
+
+ @event_priority(-100) # Last, after autorefs has finished applying cross-refs and collecting backlinks.
+ def _on_env_apply_backlinks(self, env: Environment, /, *, config: MkDocsConfig, files: Files) -> Environment: # noqa: ARG002
+ regex = re.compile(r"")
+
+ def repl(match: Match) -> str:
+ handler_name = match.group(2)
+ handler = self.handlers.get_handler(handler_name)
+
+ # The handler doesn't implement backlinks,
+ # return early to avoid computing them.
+ if handler.render_backlinks.__func__ is BaseHandler.render_backlinks:
+ return ""
+
+ identifier = match.group(1)
+ aliases = handler.get_aliases(identifier)
+ backlinks = self._autorefs.get_backlinks(identifier, *aliases, from_url=file.page.url)
+
+ # No backlinks, avoid calling the handler's method.
+ if not backlinks:
+ return ""
+
+ if "locale" in signature(handler.render_backlinks).parameters:
+ render_backlinks = partial(handler.render_backlinks, locale=self.handlers._locale)
+ else:
+ render_backlinks = handler.render_backlinks
+
+ return render_backlinks(backlinks)
+
+ for file in files:
+ if file.page and file.page.content:
+ _logger.debug("Applying backlinks in page %s", file.page.file.src_path)
+ file.page.content = regex.sub(repl, file.page.content)
+
+ return env
+
+ on_env = CombinedEvent(_on_env_load_inventories, _on_env_add_css, _on_env_write_inventory, _on_env_apply_backlinks)
+ """Extra actions that need to happen after all Markdown-to-HTML page rendering.
+
+ Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env).
+
+ - Gather results from background inventory download tasks.
+ - Write mkdocstrings' extra files (CSS, inventory) into the site directory.
+ - Apply backlinks to the HTML output of each page.
+ """
+
+ def on_post_build(
+ self,
+ config: MkDocsConfig, # noqa: ARG002
+ **kwargs: Any, # noqa: ARG002
+ ) -> None:
+ """Teardown the handlers.
+
+ Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build).
+ This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup.
+
+ For example, a handler could open a subprocess in the background and keep it open
+ to feed it "autodoc" instructions and get back JSON data. If so, it should then close the subprocess at some point:
+ the proper place to do this is in the handler's `teardown` method, which is indirectly called by this hook.
+
+ Arguments:
+ config: The MkDocs config object.
+ **kwargs: Additional arguments passed by MkDocs.
+ """
+ if not self.plugin_enabled:
+ return
+
+ if self._handlers:
+ _logger.debug("Tearing handlers down")
+ self.handlers.teardown()
+
+ def get_handler(self, handler_name: str) -> BaseHandler:
+ """Get a handler by its name. See [mkdocstrings.Handlers.get_handler][].
+
+ Arguments:
+ handler_name: The name of the handler.
+
+ Returns:
+ An instance of a subclass of [`BaseHandler`][mkdocstrings.BaseHandler].
+ """
+ return self.handlers.get_handler(handler_name)
diff --git a/src/mkdocstrings/extension.py b/src/mkdocstrings/extension.py
deleted file mode 100644
index 19326720..00000000
--- a/src/mkdocstrings/extension.py
+++ /dev/null
@@ -1,314 +0,0 @@
-"""This module holds the code of the Markdown extension responsible for matching "autodoc" instructions.
-
-The extension is composed of a Markdown [block processor](https://python-markdown.github.io/extensions/api/#blockparser)
-that matches indented blocks starting with a line like `::: identifier`.
-
-For each of these blocks, it uses a [handler][mkdocstrings.handlers.base.BaseHandler] to collect documentation about
-the given identifier and render it with Jinja templates.
-
-Both the collection and rendering process can be configured by adding YAML configuration under the "autodoc"
-instruction:
-
-```yaml
-::: some.identifier
- handler: python
- options:
- option1: value1
- option2:
- - value2a
- - value2b
- option_x: etc
-```
-"""
-
-from __future__ import annotations
-
-import re
-from collections import ChainMap
-from typing import TYPE_CHECKING, Any, MutableSequence
-from xml.etree.ElementTree import Element
-
-import yaml
-from jinja2.exceptions import TemplateNotFound
-from markdown.blockprocessors import BlockProcessor
-from markdown.extensions import Extension
-from markdown.treeprocessors import Treeprocessor
-from mkdocs.exceptions import PluginError
-
-from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers
-from mkdocstrings.loggers import get_logger
-
-if TYPE_CHECKING:
- from markdown import Markdown
- from markdown.blockparser import BlockParser
- from mkdocs_autorefs.plugin import AutorefsPlugin
-
-
-log = get_logger(__name__)
-
-
-class AutoDocProcessor(BlockProcessor):
- """Our "autodoc" Markdown block processor.
-
- It has a [`test` method][mkdocstrings.extension.AutoDocProcessor.test] that tells if a block matches a criterion,
- and a [`run` method][mkdocstrings.extension.AutoDocProcessor.run] that processes it.
-
- It also has utility methods allowing to get handlers and their configuration easily, useful when processing
- a matched block.
- """
-
- regex = re.compile(r"^(?P#{1,6} *|)::: ?(?P.+?) *$", flags=re.MULTILINE)
-
- def __init__(
- self,
- parser: BlockParser,
- md: Markdown,
- config: dict,
- handlers: Handlers,
- autorefs: AutorefsPlugin,
- ) -> None:
- """Initialize the object.
-
- Arguments:
- parser: A `markdown.blockparser.BlockParser` instance.
- md: A `markdown.Markdown` instance.
- config: The [configuration][mkdocstrings.plugin.PluginConfig] of the `mkdocstrings` plugin.
- handlers: The handlers container.
- autorefs: The autorefs plugin instance.
- """
- super().__init__(parser=parser)
- self.md = md
- self._config = config
- self._handlers = handlers
- self._autorefs = autorefs
- self._updated_envs: set = set()
-
- def test(self, parent: Element, block: str) -> bool: # noqa: ARG002
- """Match our autodoc instructions.
-
- Arguments:
- parent: The parent element in the XML tree.
- block: The block to be tested.
-
- Returns:
- Whether this block should be processed or not.
- """
- return bool(self.regex.search(block))
-
- def run(self, parent: Element, blocks: MutableSequence[str]) -> None:
- """Run code on the matched blocks.
-
- The identifier and configuration lines are retrieved from a matched block
- and used to collect and render an object.
-
- Arguments:
- parent: The parent element in the XML tree.
- blocks: The rest of the blocks to be processed.
- """
- block = blocks.pop(0)
- match = self.regex.search(block)
-
- if match:
- if match.start() > 0:
- self.parser.parseBlocks(parent, [block[: match.start()]])
- # removes the first line
- block = block[match.end() :]
-
- block, the_rest = self.detab(block)
-
- if not block and blocks and blocks[0].startswith((" handler:", " options:")):
- # YAML options were separated from the `:::` line by a blank line.
- block = blocks.pop(0)
-
- if match:
- identifier = match["name"]
- heading_level = match["heading"].count("#")
- log.debug(f"Matched '::: {identifier}'")
-
- html, handler, data = self._process_block(identifier, block, heading_level)
- el = Element("div", {"class": "mkdocstrings"})
- # The final HTML is inserted as opaque to subsequent processing, and only revealed at the end.
- el.text = self.md.htmlStash.store(html)
- # We need to duplicate the headings directly, just so 'toc' can pick them up,
- # otherwise they wouldn't appear in the final table of contents.
- # These headings are generated by the `BaseHandler.do_heading` method (Jinja filter),
- # which runs in the inner Markdown conversion layer, and not in the outer one where we are now.
- headings = handler.get_headings()
- el.extend(headings)
- # These duplicated headings will later be removed by our `_HeadingsPostProcessor` processor,
- # which runs right after 'toc' (see `MkdocstringsExtension.extendMarkdown`).
-
- page = self._autorefs.current_page
- if page is not None:
- for heading in headings:
- rendered_anchor = heading.attrib["id"]
- self._autorefs.register_anchor(page, rendered_anchor)
-
- if "data-role" in heading.attrib:
- self._handlers.inventory.register(
- name=rendered_anchor,
- domain=handler.domain,
- role=heading.attrib["data-role"],
- priority=1, # register with standard priority
- uri=f"{page}#{rendered_anchor}",
- )
-
- # also register other anchors for this object in the inventory
- try:
- data_object = handler.collect(rendered_anchor, handler.fallback_config)
- except CollectionError:
- continue
- for anchor in handler.get_anchors(data_object):
- if anchor not in self._handlers.inventory:
- self._handlers.inventory.register(
- name=anchor,
- domain=handler.domain,
- role=heading.attrib["data-role"],
- priority=2, # register with lower priority
- uri=f"{page}#{rendered_anchor}",
- )
-
- parent.append(el)
-
- if the_rest:
- # This block contained unindented line(s) after the first indented
- # line. Insert these lines as the first block of the master blocks
- # list for future processing.
- blocks.insert(0, the_rest)
-
- def _process_block(
- self,
- identifier: str,
- yaml_block: str,
- heading_level: int = 0,
- ) -> tuple[str, BaseHandler, CollectorItem]:
- """Process an autodoc block.
-
- Arguments:
- identifier: The identifier of the object to collect and render.
- yaml_block: The YAML configuration.
- heading_level: Suggested level of the heading to insert (0 to ignore).
-
- Raises:
- PluginError: When something wrong happened during collection.
- TemplateNotFound: When a template used for rendering could not be found.
-
- Returns:
- Rendered HTML, the handler that was used, and the collected item.
- """
- config = yaml.safe_load(yaml_block) or {}
- handler_name = self._handlers.get_handler_name(config)
-
- log.debug(f"Using handler '{handler_name}'")
- handler_config = self._handlers.get_handler_config(handler_name)
- handler = self._handlers.get_handler(handler_name, handler_config)
-
- global_options = handler_config.get("options", {})
- local_options = config.get("options", {})
- options = ChainMap(local_options, global_options)
-
- if heading_level:
- # Heading level obtained from Markdown (`##`) takes precedence.
- options = ChainMap({"heading_level": heading_level}, options)
-
- log.debug("Collecting data")
- try:
- data: CollectorItem = handler.collect(identifier, options)
- except CollectionError as exception:
- log.error(str(exception)) # noqa: TRY400
- raise PluginError(f"Could not collect '{identifier}'") from exception
-
- if handler_name not in self._updated_envs: # We haven't seen this handler before on this document.
- log.debug("Updating handler's rendering env")
- handler._update_env(self.md, self._config)
- self._updated_envs.add(handler_name)
-
- log.debug("Rendering templates")
- try:
- rendered = handler.render(data, options)
- except TemplateNotFound as exc:
- theme_name = self._config["theme_name"]
- log.error( # noqa: TRY400
- f"Template '{exc.name}' not found for '{handler_name}' handler and theme '{theme_name}'.",
- )
- raise
-
- return rendered, handler, data
-
-
-class _HeadingsPostProcessor(Treeprocessor):
- def run(self, root: Element) -> None:
- self._remove_duplicated_headings(root)
-
- def _remove_duplicated_headings(self, parent: Element) -> None:
- carry_text = ""
- for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration.
- if el.tag == "div" and el.get("class") == "mkdocstrings":
- # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML).
- carry_text = (el.text or "") + carry_text
- parent.remove(el)
- else:
- if carry_text:
- el.tail = (el.tail or "") + carry_text
- carry_text = ""
- self._remove_duplicated_headings(el)
-
- if carry_text:
- parent.text = (parent.text or "") + carry_text
-
-
-class _TocLabelsTreeProcessor(Treeprocessor):
- def run(self, root: Element) -> None: # noqa: ARG002
- self._override_toc_labels(self.md.toc_tokens) # type: ignore[attr-defined]
-
- def _override_toc_labels(self, tokens: list[dict[str, Any]]) -> None:
- for token in tokens:
- if (label := token.get("data-toc-label")) and token["name"] != label:
- token["name"] = label
- self._override_toc_labels(token["children"])
-
-
-class MkdocstringsExtension(Extension):
- """Our Markdown extension.
-
- It cannot work outside of `mkdocstrings`.
- """
-
- def __init__(self, config: dict, handlers: Handlers, autorefs: AutorefsPlugin, **kwargs: Any) -> None:
- """Initialize the object.
-
- Arguments:
- config: The configuration items from `mkdocs` and `mkdocstrings` that must be passed to the block processor
- when instantiated in [`extendMarkdown`][mkdocstrings.extension.MkdocstringsExtension.extendMarkdown].
- handlers: The handlers container.
- autorefs: The autorefs plugin instance.
- **kwargs: Keyword arguments used by `markdown.extensions.Extension`.
- """
- super().__init__(**kwargs)
- self._config = config
- self._handlers = handlers
- self._autorefs = autorefs
-
- def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
- """Register the extension.
-
- Add an instance of our [`AutoDocProcessor`][mkdocstrings.extension.AutoDocProcessor] to the Markdown parser.
-
- Arguments:
- md: A `markdown.Markdown` instance.
- """
- md.parser.blockprocessors.register(
- AutoDocProcessor(md.parser, md, self._config, self._handlers, self._autorefs),
- "mkdocstrings",
- priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor
- )
- md.treeprocessors.register(
- _HeadingsPostProcessor(md),
- "mkdocstrings_post_headings",
- priority=4, # Right after 'toc'.
- )
- md.treeprocessors.register(
- _TocLabelsTreeProcessor(md),
- "mkdocstrings_post_toc_labels",
- priority=4, # Right after 'toc'.
- )
diff --git a/src/mkdocstrings/handlers/__init__.py b/src/mkdocstrings/handlers/__init__.py
deleted file mode 100644
index b9e2a29c..00000000
--- a/src/mkdocstrings/handlers/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Handlers module."""
diff --git a/src/mkdocstrings/plugin.py b/src/mkdocstrings/plugin.py
deleted file mode 100644
index 48a7d1ab..00000000
--- a/src/mkdocstrings/plugin.py
+++ /dev/null
@@ -1,324 +0,0 @@
-"""This module contains the "mkdocstrings" plugin for MkDocs.
-
-The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]),
-and adds it to the list of Markdown extensions used by `mkdocs`
-during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config).
-
-Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build)
-is triggered and calls the [`handlers.teardown()` method][mkdocstrings.handlers.base.Handlers.teardown]. This method is
-used to teardown the handlers that were instantiated during documentation buildup.
-
-Finally, when serving the documentation, it can add directories to watch
-during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve).
-"""
-
-from __future__ import annotations
-
-import datetime
-import functools
-import os
-import sys
-from concurrent import futures
-from io import BytesIO
-from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Mapping, Tuple, TypeVar
-
-from mkdocs.config import Config
-from mkdocs.config import config_options as opt
-from mkdocs.plugins import BasePlugin
-from mkdocs.utils import write_file
-from mkdocs_autorefs.plugin import AutorefsPlugin
-
-from mkdocstrings._cache import download_and_cache_url, download_url_with_gz
-from mkdocstrings.extension import MkdocstringsExtension
-from mkdocstrings.handlers.base import BaseHandler, Handlers
-from mkdocstrings.loggers import get_logger
-
-if TYPE_CHECKING:
- from jinja2.environment import Environment
- from mkdocs.config.defaults import MkDocsConfig
-
-if sys.version_info < (3, 10):
- from typing_extensions import ParamSpec
-else:
- from typing import ParamSpec
-
-log = get_logger(__name__)
-
-InventoryImportType = List[Tuple[str, Mapping[str, Any]]]
-InventoryLoaderType = Callable[..., Iterable[Tuple[str, str]]]
-
-P = ParamSpec("P")
-R = TypeVar("R")
-
-
-def list_to_tuple(function: Callable[P, R]) -> Callable[P, R]:
- """Decorater to convert lists to tuples in the arguments."""
-
- def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
- safe_args = [tuple(item) if isinstance(item, list) else item for item in args]
- if kwargs:
- kwargs = {key: tuple(value) if isinstance(value, list) else value for key, value in kwargs.items()} # type: ignore[assignment]
- return function(*safe_args, **kwargs) # type: ignore[arg-type]
-
- return wrapper
-
-
-class PluginConfig(Config):
- """The configuration options of `mkdocstrings`, written in `mkdocs.yml`."""
-
- handlers = opt.Type(dict, default={})
- """
- Global configuration of handlers.
-
- You can set global configuration per handler, applied everywhere,
- but overridable in each "autodoc" instruction. Example:
-
- ```yaml
- plugins:
- - mkdocstrings:
- handlers:
- python:
- options:
- option1: true
- option2: "value"
- rust:
- options:
- option9: 2
- ```
- """
-
- default_handler = opt.Type(str, default="python")
- """The default handler to use. The value is the name of the handler module. Default is "python"."""
- custom_templates = opt.Optional(opt.Dir(exists=True))
- """Location of custom templates to use when rendering API objects.
-
- Value should be the path of a directory relative to the MkDocs configuration file.
- """
- enable_inventory = opt.Optional(opt.Type(bool))
- """Whether to enable object inventory creation."""
- enabled = opt.Type(bool, default=True)
- """Whether to enable the plugin. Default is true. If false, *mkdocstrings* will not collect or render anything."""
-
-
-class MkdocstringsPlugin(BasePlugin[PluginConfig]):
- """An `mkdocs` plugin.
-
- This plugin defines the following event hooks:
-
- - `on_config`
- - `on_env`
- - `on_post_build`
-
- Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
- for more information about its plugin system.
- """
-
- css_filename = "assets/_mkdocstrings.css"
-
- def __init__(self) -> None:
- """Initialize the object."""
- super().__init__()
- self._handlers: Handlers | None = None
-
- @property
- def handlers(self) -> Handlers:
- """Get the instance of [mkdocstrings.handlers.base.Handlers][] for this plugin/build.
-
- Raises:
- RuntimeError: If the plugin hasn't been initialized with a config.
-
- Returns:
- An instance of [mkdocstrings.handlers.base.Handlers][] (the same throughout the build).
- """
- if not self._handlers:
- raise RuntimeError("The plugin hasn't been initialized with a config yet")
- return self._handlers
-
- def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
- """Instantiate our Markdown extension.
-
- Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
- In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]
- and add it to the list of Markdown extensions used by `mkdocs`.
-
- We pass this plugin's configuration dictionary to the extension when instantiating it (it will need it
- later when processing markdown to get handlers and their global configurations).
-
- Arguments:
- config: The MkDocs config object.
-
- Returns:
- The modified config.
- """
- if not self.plugin_enabled:
- log.debug("Plugin is not enabled. Skipping.")
- return config
- log.debug("Adding extension to the list")
-
- theme_name = config.theme.name or os.path.dirname(config.theme.dirs[0])
-
- to_import: InventoryImportType = []
- for handler_name, conf in self.config.handlers.items():
- for import_item in conf.pop("import", ()):
- if isinstance(import_item, str):
- import_item = {"url": import_item} # noqa: PLW2901
- to_import.append((handler_name, import_item))
-
- extension_config = {
- "theme_name": theme_name,
- "mdx": config.markdown_extensions,
- "mdx_configs": config.mdx_configs,
- "mkdocstrings": self.config,
- "mkdocs": config,
- }
- self._handlers = Handlers(extension_config)
-
- autorefs: AutorefsPlugin
- try:
- # If autorefs plugin is explicitly enabled, just use it.
- autorefs = config.plugins["autorefs"] # type: ignore[assignment]
- log.debug(f"Picked up existing autorefs instance {autorefs!r}")
- except KeyError:
- # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`.
- autorefs = AutorefsPlugin()
- autorefs.scan_toc = False
- config.plugins["autorefs"] = autorefs
- log.debug(f"Added a subdued autorefs instance {autorefs!r}")
- # Add collector-based fallback in either case.
- autorefs.get_fallback_anchor = self.handlers.get_anchors
-
- mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers, autorefs)
- config.markdown_extensions.append(mkdocstrings_extension) # type: ignore[arg-type]
-
- config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files.
-
- self._inv_futures = {}
- if to_import:
- inv_loader = futures.ThreadPoolExecutor(4)
- for handler_name, import_item in to_import:
- loader = self.get_handler(handler_name).load_inventory
- future = inv_loader.submit(
- self._load_inventory, # type: ignore[misc]
- loader,
- **import_item,
- )
- self._inv_futures[future] = (loader, import_item)
- inv_loader.shutdown(wait=False)
-
- return config
-
- @property
- def inventory_enabled(self) -> bool:
- """Tell if the inventory is enabled or not.
-
- Returns:
- Whether the inventory is enabled.
- """
- inventory_enabled = self.config.enable_inventory
- if inventory_enabled is None:
- inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers)
- return inventory_enabled
-
- @property
- def plugin_enabled(self) -> bool:
- """Tell if the plugin is enabled or not.
-
- Returns:
- Whether the plugin is enabled.
- """
- return self.config.enabled
-
- def on_env(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
- """Extra actions that need to happen after all Markdown rendering and before HTML rendering.
-
- Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env).
-
- - Write mkdocstrings' extra files into the site dir.
- - Gather results from background inventory download tasks.
- """
- if not self.plugin_enabled:
- return
- if self._handlers:
- css_content = "\n".join(handler.extra_css for handler in self.handlers.seen_handlers)
- write_file(css_content.encode("utf-8"), os.path.join(config.site_dir, self.css_filename))
-
- if self.inventory_enabled:
- log.debug("Creating inventory file objects.inv")
- inv_contents = self.handlers.inventory.format_sphinx()
- write_file(inv_contents, os.path.join(config.site_dir, "objects.inv"))
-
- if self._inv_futures:
- log.debug(f"Waiting for {len(self._inv_futures)} inventory download(s)")
- futures.wait(self._inv_futures, timeout=30)
- results = {}
- # Reversed order so that pages from first futures take precedence:
- for fut in reversed(list(self._inv_futures)):
- try:
- results.update(fut.result())
- except Exception as error: # noqa: BLE001
- loader, import_item = self._inv_futures[fut]
- loader_name = loader.__func__.__qualname__
- log.error(f"Couldn't load inventory {import_item} through {loader_name}: {error}") # noqa: TRY400
- for page, identifier in results.items():
- config.plugins["autorefs"].register_url(page, identifier) # type: ignore[attr-defined]
- self._inv_futures = {}
-
- def on_post_build(
- self,
- config: MkDocsConfig, # noqa: ARG002
- **kwargs: Any, # noqa: ARG002
- ) -> None:
- """Teardown the handlers.
-
- Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build).
- This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup.
-
- For example, a handler could open a subprocess in the background and keep it open
- to feed it "autodoc" instructions and get back JSON data. If so, it should then close the subprocess at some point:
- the proper place to do this is in the handler's `teardown` method, which is indirectly called by this hook.
-
- Arguments:
- config: The MkDocs config object.
- **kwargs: Additional arguments passed by MkDocs.
- """
- if not self.plugin_enabled:
- return
-
- for future in self._inv_futures:
- future.cancel()
-
- if self._handlers:
- log.debug("Tearing handlers down")
- self.handlers.teardown()
-
- def get_handler(self, handler_name: str) -> BaseHandler:
- """Get a handler by its name. See [mkdocstrings.handlers.base.Handlers.get_handler][].
-
- Arguments:
- handler_name: The name of the handler.
-
- Returns:
- An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler].
- """
- return self.handlers.get_handler(handler_name)
-
- @classmethod
- # lru_cache does not allow mutable arguments such lists, but that is what we load from YAML config.
- @list_to_tuple
- @functools.lru_cache(maxsize=None)
- def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) -> Mapping[str, str]:
- """Download and process inventory files using a handler.
-
- Arguments:
- loader: A function returning a sequence of pairs (identifier, url).
- url: The URL to download and process.
- **kwargs: Extra arguments to pass to the loader.
-
- Returns:
- A mapping from identifier to absolute URL.
- """
- log.debug(f"Downloading inventory from {url!r}")
- content = download_and_cache_url(url, download_url_with_gz, datetime.timedelta(days=1))
- result = dict(loader(BytesIO(content), url=url, **kwargs))
- log.debug(f"Loaded inventory from {url!r}: {len(result)} items")
- return result
diff --git a/tests/conftest.py b/tests/conftest.py
index 9bb09368..a2a40652 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,18 +3,19 @@
from __future__ import annotations
from collections import ChainMap
-from typing import TYPE_CHECKING, Any, Iterator
+from typing import TYPE_CHECKING, Any
import pytest
from markdown.core import Markdown
from mkdocs.config.defaults import MkDocsConfig
if TYPE_CHECKING:
+ from collections.abc import Iterator
from pathlib import Path
from mkdocs import config
- from mkdocstrings.plugin import MkdocstringsPlugin
+ from mkdocstrings._internal.plugin import MkdocstringsPlugin
@pytest.fixture(name="mkdocs_conf")
@@ -22,7 +23,7 @@ def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Itera
"""Yield a MkDocs configuration object."""
conf = MkDocsConfig()
while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"):
- request = request._parent_request
+ request = request._parent_request # ty: ignore[invalid-assignment]
conf_dict = {
"site_name": "foo",
@@ -32,7 +33,7 @@ def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> Itera
**getattr(request, "param", {}),
}
# Re-create it manually as a workaround for https://github.com/mkdocs/mkdocs/issues/2289
- mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", [])))
+ mdx_configs: dict[str, Any] = dict(ChainMap(*conf_dict.get("markdown_extensions", []))) # ty: ignore[invalid-argument-type,invalid-assignment]
conf.load_dict(conf_dict)
assert conf.validate() == ([], [])
diff --git a/tests/fixtures/nesting.py b/tests/fixtures/nesting.py
new file mode 100644
index 00000000..92f7a9ee
--- /dev/null
+++ b/tests/fixtures/nesting.py
@@ -0,0 +1,10 @@
+class Class:
+ """A class.
+
+ ## ::: tests.fixtures.nesting.Class.method
+ options:
+ show_root_heading: true
+ """
+
+ def method(self) -> None:
+ """A method."""
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 00000000..7ae732cb
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,184 @@
+"""Tests for our own API exposition."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import griffe
+import pytest
+
+import mkdocstrings
+from mkdocstrings import Inventory
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+
+@pytest.fixture(name="loader", scope="module")
+def _fixture_loader() -> griffe.GriffeLoader:
+ loader = griffe.GriffeLoader()
+ loader.load("mkdocstrings")
+ loader.resolve_aliases()
+ return loader
+
+
+@pytest.fixture(name="internal_api", scope="module")
+def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module:
+ return loader.modules_collection["mkdocstrings._internal"]
+
+
+@pytest.fixture(name="public_api", scope="module")
+def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module:
+ return loader.modules_collection["mkdocstrings"]
+
+
+def _yield_public_objects(
+ obj: griffe.Module | griffe.Class,
+ *,
+ modules: bool = False,
+ modulelevel: bool = True,
+ inherited: bool = False,
+ special: bool = False,
+) -> Iterator[griffe.Object | griffe.Alias]:
+ for member in obj.all_members.values() if inherited else obj.members.values():
+ try:
+ if member.is_module:
+ if member.is_alias or not member.is_public:
+ continue
+ if modules:
+ yield member
+ yield from _yield_public_objects(
+ member, # ty: ignore[invalid-argument-type]
+ modules=modules,
+ modulelevel=modulelevel,
+ inherited=inherited,
+ special=special,
+ )
+ elif member.is_public and (special or not member.is_special):
+ yield member
+ else:
+ continue
+ if member.is_class and not modulelevel:
+ yield from _yield_public_objects(
+ member, # ty: ignore[invalid-argument-type]
+ modules=modules,
+ modulelevel=False,
+ inherited=inherited,
+ special=special,
+ )
+ except (griffe.AliasResolutionError, griffe.CyclicAliasError):
+ continue
+
+
+@pytest.fixture(name="modulelevel_internal_objects", scope="module")
+def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
+ return list(_yield_public_objects(internal_api, modulelevel=True))
+
+
+@pytest.fixture(name="internal_objects", scope="module")
+def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
+ return list(_yield_public_objects(internal_api, modulelevel=False, special=True))
+
+
+@pytest.fixture(name="public_objects", scope="module")
+def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]:
+ return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True))
+
+
+@pytest.fixture(name="inventory", scope="module")
+def _fixture_inventory() -> Inventory:
+ inventory_file = Path(__file__).parent.parent / "site" / "objects.inv"
+ if not inventory_file.exists():
+ pytest.skip("The objects inventory is not available.")
+ with inventory_file.open("rb") as file:
+ return Inventory.parse_sphinx(file)
+
+
+def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
+ """All public objects in the internal API are exposed under `mkdocstrings`."""
+ not_exposed = [
+ obj.path
+ for obj in modulelevel_internal_objects
+ if obj.name not in mkdocstrings.__all__ or not hasattr(mkdocstrings, obj.name)
+ ]
+ assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed))
+
+
+def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None:
+ """All internal objects have unique names."""
+ names_to_paths = defaultdict(list)
+ for obj in modulelevel_internal_objects:
+ names_to_paths[obj.name].append(obj.path)
+ non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1]
+ assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique)
+
+
+def test_single_locations(public_api: griffe.Module) -> None:
+ """All objects have a single public location."""
+
+ def _public_path(obj: griffe.Object | griffe.Alias) -> bool:
+ return obj.is_public and (obj.parent is None or _public_path(obj.parent))
+
+ multiple_locations = {}
+ for obj_name in mkdocstrings.__all__:
+ obj = public_api[obj_name]
+ if obj.aliases and (
+ public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)]
+ ):
+ multiple_locations[obj.path] = public_aliases
+ assert not multiple_locations, "Multiple public locations:\n" + "\n".join(
+ f"{path}: {aliases}" for path, aliases in multiple_locations.items()
+ )
+
+
+def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None:
+ """All public objects are added to the inventory."""
+ ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"}
+ not_in_inventory = [
+ f"{obj.relative_filepath}:{obj.lineno}: {obj.path}"
+ for obj in public_objects
+ if obj.name not in ignore_names and obj.path not in inventory
+ ]
+ msg = "Objects not in the inventory (try running `make run zensical build --clean`):\n{paths}"
+ assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory)))
+
+
+def test_inventory_matches_api(
+ inventory: Inventory,
+ public_objects: list[griffe.Object | griffe.Alias],
+ loader: griffe.GriffeLoader,
+) -> None:
+ """The inventory doesn't contain any additional Python object."""
+ not_in_api = []
+ public_api_paths = {obj.path for obj in public_objects}
+ public_api_paths.add("mkdocstrings")
+ for item in inventory.values():
+ if (
+ item.domain == "py"
+ and "(" not in item.name
+ and (item.name == "mkdocstrings" or item.name.startswith("mkdocstrings."))
+ ):
+ obj = loader.modules_collection[item.name]
+
+ if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases):
+ not_in_api.append(item.name)
+ msg = "Inventory objects not in public API (try running `make run zensical build --clean`):\n{paths}"
+ assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api)))
+
+
+def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None:
+ """No module docstrings should be written in our internal API.
+
+ The reasoning is that docstrings are addressed to users of the public API,
+ but internal modules are not exposed to users, so they should not have docstrings.
+ """
+
+ def _modules(obj: griffe.Module) -> Iterator[griffe.Module]:
+ for member in obj.modules.values():
+ yield member
+ yield from _modules(member)
+
+ for obj in _modules(internal_api):
+ assert not obj.docstring
diff --git a/tests/test_download.py b/tests/test_download.py
new file mode 100644
index 00000000..4aa19fd7
--- /dev/null
+++ b/tests/test_download.py
@@ -0,0 +1,103 @@
+"""Tests for the internal mkdocstrings _download module."""
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING
+
+import pytest
+
+from mkdocstrings._internal import download
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+
+@pytest.mark.parametrize(
+ ("credential", "expected", "env"),
+ [
+ ("USER", "USER", {"USER": "testuser"}),
+ ("$USER", "$USER", {"USER": "testuser"}),
+ ("${USER", "${USER", {"USER": "testuser"}),
+ ("$USER}", "$USER}", {"USER": "testuser"}),
+ ("${TOKEN}", "testtoken", {"TOKEN": "testtoken"}),
+ ("${USER}:${PASSWORD}", "${USER}:testpass", {"PASSWORD": "testpass"}),
+ ("${USER}:${PASSWORD}", "testuser:testpass", {"USER": "testuser", "PASSWORD": "testpass"}),
+ (
+ "user_prefix_${USER}_user_$uffix:pwd_prefix_${PASSWORD}_pwd_${uffix",
+ "user_prefix_testuser_user_$uffix:pwd_prefix_testpass_pwd_${uffix",
+ {"USER": "testuser", "PASSWORD": "testpass"},
+ ),
+ ],
+)
+def test_expand_env_vars(credential: str, expected: str, env: Mapping[str, str]) -> None:
+ """Test expanding environment variables."""
+ assert download._expand_env_vars(credential, url="https://test.example.com", env=env) == expected
+
+
+def test_expand_env_vars_with_missing_env_var(caplog: pytest.LogCaptureFixture) -> None:
+ """Test expanding environment variables with a missing environment variable."""
+ caplog.set_level(logging.WARNING, logger="mkdocs.plugins.mkdocstrings._download")
+
+ credential = "${USER}"
+ env: dict[str, str] = {}
+ assert download._expand_env_vars(credential, url="https://test.example.com", env=env) == "${USER}"
+
+ output = caplog.records[0].getMessage()
+ assert "'USER' is not set" in output
+
+
+@pytest.mark.parametrize(
+ ("url", "expected_url"),
+ [
+ ("http://host/path", "http://host/path"),
+ ("http://token@host/path", "http://host/path"),
+ ("http://${token}@host/path", "http://host/path"),
+ ("http://username:password@host/path", "http://host/path"),
+ ("http://username:${PASSWORD}@host/path", "http://host/path"),
+ ("http://${USERNAME}:${PASSWORD}@host/path", "http://host/path"),
+ ("http://prefix${USERNAME}suffix:prefix${PASSWORD}suffix@host/path", "http://host/path"),
+ ],
+)
+def test_extract_auth_from_url(monkeypatch: pytest.MonkeyPatch, url: str, expected_url: str) -> None:
+ """Test extracting the auth part from the URL."""
+ monkeypatch.setattr(download, "_create_auth_header", lambda *args, **kwargs: {})
+ result_url, _result_auth_header = download._extract_auth_from_url(url)
+ assert result_url == expected_url
+
+
+def test_create_auth_header_basic_auth() -> None:
+ """Test creating the Authorization header for basic authentication."""
+ auth_header = download._create_auth_header(credential="testuser:testpass", url="https://test.example.com")
+ assert auth_header == {"Authorization": "Basic dGVzdHVzZXI6dGVzdHBhc3M="}
+
+
+def test_create_auth_header_bearer_auth() -> None:
+ """Test creating the Authorization header for bearer token authentication."""
+ auth_header = download._create_auth_header(credential="token123", url="https://test.example.com")
+ assert auth_header == {"Authorization": "Bearer token123"}
+
+
+@pytest.mark.parametrize(
+ ("var", "match"),
+ [
+ ("${var}", "var"),
+ ("${VAR}", "VAR"),
+ ("${_}", "_"),
+ ("${_VAR}", "_VAR"),
+ ("${VAR123}", "VAR123"),
+ ("${VAR123_}", "VAR123_"),
+ ("VAR", None),
+ ("$1VAR", None),
+ ("${1VAR}", None),
+ ("${}", None),
+ ("${ }", None),
+ ],
+)
+def test_env_var_pattern(var: str, match: str | None) -> None:
+ """Test the environment variable regex pattern."""
+ _match = download._ENV_VAR_PATTERN.match(var)
+ if _match is None:
+ assert match is _match
+ else:
+ assert _match.group(1) == match
diff --git a/tests/test_extension.py b/tests/test_extension.py
index 976f376c..5b031842 100644
--- a/tests/test_extension.py
+++ b/tests/test_extension.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import re
-import sys
from textwrap import dedent
from typing import TYPE_CHECKING
@@ -12,7 +11,7 @@
if TYPE_CHECKING:
from markdown import Markdown
- from mkdocstrings.plugin import MkdocstringsPlugin
+ from mkdocstrings import MkdocstringsPlugin
@pytest.mark.parametrize("ext_markdown", [{"markdown_extensions": [{"footnotes": {}}]}], indirect=["ext_markdown"])
@@ -60,7 +59,6 @@ def test_reference_inside_autodoc(ext_markdown: Markdown) -> None:
assert re.search(r"Link to <.*something\.Else.*>something\.Else<.*>\.", output)
-@pytest.mark.skipif(sys.version_info < (3, 8), reason="typing.Literal requires Python 3.8")
def test_quote_inside_annotation(ext_markdown: Markdown) -> None:
"""Assert that inline highlighting doesn't double-escape HTML."""
output = ext_markdown.convert("::: tests.fixtures.string_annotation.Foo")
@@ -101,7 +99,7 @@ def test_no_double_toc(ext_markdown: Markdown, expect_permalink: str) -> None:
)
assert output.count(expect_permalink) == 5
assert 'id="tests.fixtures.headings--foo"' in output
- assert ext_markdown.toc_tokens == [ # type: ignore[attr-defined] # the member gets populated only with 'toc' extension
+ assert ext_markdown.toc_tokens == [ # ty: ignore[unresolved-attribute]
{
"level": 1,
"id": "aa",
@@ -154,16 +152,20 @@ def test_use_custom_handler(ext_markdown: Markdown) -> None:
ext_markdown.convert("::: tests.fixtures.headings\n handler: not_here")
-def test_dont_register_every_identifier_as_anchor(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> None:
+def test_register_every_identifier_alias(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> None:
"""Assert that we don't preemptively register all identifiers of a rendered object."""
- handler = plugin._handlers.get_handler("python") # type: ignore[union-attr]
+ handler = plugin._handlers.get_handler("python") # ty: ignore[unresolved-attribute]
ids = ("id1", "id2", "id3")
- handler.get_anchors = lambda _: ids # type: ignore[method-assign]
+ handler.get_aliases = lambda _: ids # ty: ignore[invalid-assignment]
+ autorefs = ext_markdown.parser.blockprocessors["mkdocstrings"]._autorefs
+
+ class Page:
+ url = "foo"
+
+ autorefs.current_page = Page()
ext_markdown.convert("::: tests.fixtures.headings")
- autorefs = ext_markdown.parser.blockprocessors["mkdocstrings"]._autorefs # type: ignore[attr-defined]
for identifier in ids:
- assert identifier not in autorefs._url_map
- assert identifier not in autorefs._abs_url_map
+ assert identifier in autorefs._secondary_url_map
def test_use_options_yaml_key(ext_markdown: Markdown) -> None:
@@ -196,7 +198,6 @@ def test_removing_duplicated_headings(ext_markdown: Markdown) -> None:
assert output.count(">Heading one<") == 1
assert output.count(">Heading two<") == 1
assert output.count(">Heading three<") == 1
- assert output.count('class="mkdocstrings') == 0
def _assert_contains_in_order(items: list[str], string: str) -> None:
diff --git a/tests/test_handlers.py b/tests/test_handlers.py
index 4a07e98b..a1ef4ee6 100644
--- a/tests/test_handlers.py
+++ b/tests/test_handlers.py
@@ -2,18 +2,20 @@
from __future__ import annotations
+from textwrap import dedent
from typing import TYPE_CHECKING
import pytest
+from dirty_equals import IsStr
from jinja2.exceptions import TemplateNotFound
from markdown import Markdown
-from mkdocstrings.handlers.base import Highlighter
+from mkdocstrings import Highlighter
if TYPE_CHECKING:
from pathlib import Path
- from mkdocstrings.plugin import MkdocstringsPlugin
+ from mkdocstrings import MkdocstringsPlugin
@pytest.mark.parametrize("extension_name", ["codehilite", "pymdownx.highlight"])
@@ -60,7 +62,7 @@ def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None:
tmp_path: Temporary folder.
plugin: Instance of our plugin.
"""
- handler = plugin._handlers.get_handler("python") # type: ignore[union-attr]
+ handler = plugin._handlers.get_handler("python") # ty: ignore[unresolved-attribute]
# monkeypatch Jinja env search path
search_paths = [
@@ -69,7 +71,7 @@ def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None:
extended_theme := tmp_path / "extended_theme",
extended_fallback_theme := tmp_path / "extended_fallback_theme",
]
- handler.env.loader.searchpath = search_paths # type: ignore[union-attr]
+ handler.env.loader.searchpath = search_paths # ty: ignore[invalid-assignment]
# assert "new" template is not found
with pytest.raises(expected_exception=TemplateNotFound):
@@ -94,3 +96,43 @@ def test_extended_templates(tmp_path: Path, plugin: MkdocstringsPlugin) -> None:
base_theme.mkdir()
base_theme.joinpath("new.html").write_text("base new")
assert handler.env.get_template("new.html").render() == "base new"
+
+
+@pytest.mark.parametrize(
+ "ext_markdown",
+ [{"markdown_extensions": [{"toc": {"permalink": True}}]}],
+ indirect=["ext_markdown"],
+)
+def test_nested_autodoc(ext_markdown: Markdown) -> None:
+ """Assert that nested autodocs render well and do not mess up the TOC."""
+ output = ext_markdown.convert(
+ dedent(
+ """
+ # ::: tests.fixtures.nesting.Class
+ options:
+ members: false
+ show_root_heading: true
+ """,
+ ),
+ )
+ assert 'id="tests.fixtures.nesting.Class"' in output
+ assert 'id="tests.fixtures.nesting.Class.method"' in output
+ assert ext_markdown.toc_tokens == [ # ty: ignore[unresolved-attribute]
+ {
+ "level": 1,
+ "id": "tests.fixtures.nesting.Class",
+ "html": IsStr(),
+ "name": "Class",
+ "data-toc-label": "Class",
+ "children": [
+ {
+ "level": 2,
+ "id": "tests.fixtures.nesting.Class.method",
+ "html": IsStr(),
+ "name": "method",
+ "data-toc-label": "method",
+ "children": [],
+ },
+ ],
+ },
+ ]
diff --git a/tests/test_inventory.py b/tests/test_inventory.py
index ce707296..858ac340 100644
--- a/tests/test_inventory.py
+++ b/tests/test_inventory.py
@@ -2,20 +2,15 @@
from __future__ import annotations
-import sys
from io import BytesIO
from os.path import join
-from typing import TYPE_CHECKING
+from pathlib import Path
import pytest
from mkdocs.commands.build import build
from mkdocs.config import load_config
-from mkdocstrings.inventory import Inventory, InventoryItem
-
-if TYPE_CHECKING:
- from mkdocstrings.plugin import MkdocstringsPlugin
-sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed")
+from mkdocstrings import Inventory, InventoryItem
@pytest.mark.parametrize(
@@ -25,10 +20,13 @@
Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url")]),
Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#object_path")]),
Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]),
+ Inventory([InventoryItem(name="o", domain="py", role="obj", uri="u#o", dispname="first line\nsecond line")]),
],
)
def test_sphinx_load_inventory_file(our_inv: Inventory) -> None:
"""Perform the 'live' inventory load test."""
+ sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed")
+
buffer = BytesIO(our_inv.format_sphinx())
sphinx_inv = sphinx.InventoryFile.load(buffer, "", join)
@@ -39,9 +37,10 @@ def test_sphinx_load_inventory_file(our_inv: Inventory) -> None:
assert item.name in sphinx_inv[f"{item.domain}:{item.role}"]
-@pytest.mark.skipif(sys.version_info < (3, 7), reason="using plugins that require Python 3.7")
def test_sphinx_load_mkdocstrings_inventory_file() -> None:
"""Perform the 'live' inventory load test on mkdocstrings own inventory."""
+ sphinx = pytest.importorskip("sphinx.util.inventory", reason="Sphinx is not installed")
+
mkdocs_config = load_config()
mkdocs_config["plugins"].run_event("startup", command="build", dirty=False)
try:
@@ -50,7 +49,7 @@ def test_sphinx_load_mkdocstrings_inventory_file() -> None:
mkdocs_config["plugins"].run_event("shutdown")
own_inv = mkdocs_config["plugins"]["mkdocstrings"].handlers.inventory
- with open("site/objects.inv", "rb") as fp:
+ with Path("site/objects.inv").open("rb") as fp:
sphinx_inv = sphinx.InventoryFile.load(fp, "", join)
sphinx_inv_length = sum(len(sphinx_inv[key]) for key in sphinx_inv)
@@ -60,10 +59,27 @@ def test_sphinx_load_mkdocstrings_inventory_file() -> None:
assert item.name in sphinx_inv[f"{item.domain}:{item.role}"]
-def test_load_inventory(plugin: MkdocstringsPlugin) -> None:
- """Test the plugin inventory loading method.
-
- Parameters:
- plugin: A mkdocstrings plugin instance.
- """
- plugin._load_inventory(loader=lambda *args, **kwargs: (), url="https://example.com", domains=["a", "b"]) # type: ignore[misc,arg-type]
+@pytest.mark.parametrize(
+ "our_inv",
+ [
+ Inventory(),
+ Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url")]),
+ Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#object_path")]),
+ Inventory([InventoryItem(name="object_path", domain="py", role="obj", uri="page_url#other_anchor")]),
+ Inventory([InventoryItem(name="o", domain="py", role="obj", uri="u#o", dispname="first line\nsecond line")]),
+ ],
+)
+def test_mkdocstrings_roundtrip_inventory_file(our_inv: Inventory) -> None:
+ """Save some inventory files, then load them in again."""
+ buffer = BytesIO(our_inv.format_sphinx())
+ round_tripped = Inventory.parse_sphinx(buffer)
+
+ assert our_inv.keys() == round_tripped.keys()
+ for key, value in our_inv.items():
+ round_tripped_item = round_tripped[key]
+ assert round_tripped_item.name == value.name
+ assert round_tripped_item.domain == value.domain
+ assert round_tripped_item.role == value.role
+ assert round_tripped_item.uri == value.uri
+ assert round_tripped_item.priority == value.priority
+ assert round_tripped_item.dispname == value.dispname.splitlines()[0]
diff --git a/tests/test_loggers.py b/tests/test_loggers.py
index 1644c0f0..35e4dc86 100644
--- a/tests/test_loggers.py
+++ b/tests/test_loggers.py
@@ -4,7 +4,7 @@
import pytest
-from mkdocstrings.loggers import get_logger, get_template_logger
+from mkdocstrings import get_logger, get_template_logger
@pytest.mark.parametrize(
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 3342e2aa..833de692 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -7,7 +7,7 @@
from mkdocs.commands.build import build
from mkdocs.config import load_config
-from mkdocstrings.plugin import MkdocstringsPlugin
+from mkdocstrings import MkdocstringsPlugin
if TYPE_CHECKING:
from pathlib import Path
@@ -20,11 +20,20 @@ def test_disabling_plugin(tmp_path: Path) -> None:
docs_dir.mkdir()
site_dir.mkdir()
docs_dir.joinpath("index.md").write_text("::: mkdocstrings")
+ config_file = tmp_path / "mkdocs.yml"
+ config_file.write_text(
+ """
+ site_name: Test
+ theme: mkdocs
+ plugins:
+ - mkdocstrings:
+ enabled: false
+ """,
+ )
- mkdocs_config = load_config()
+ mkdocs_config = load_config(str(config_file))
mkdocs_config["docs_dir"] = str(docs_dir)
mkdocs_config["site_dir"] = str(site_dir)
- mkdocs_config["plugins"]["mkdocstrings"].config["enabled"] = False
mkdocs_config["plugins"].run_event("startup", command="build", dirty=False)
try:
build(mkdocs_config)
@@ -32,7 +41,7 @@ def test_disabling_plugin(tmp_path: Path) -> None:
mkdocs_config["plugins"].run_event("shutdown")
# make sure the instruction was not processed
- assert "::: mkdocstrings" in site_dir.joinpath("index.html").read_text()
+ assert "::: mkdocstrings" in site_dir.joinpath("index.html").read_text(encoding="utf8")
def test_plugin_default_config(tmp_path: Path) -> None:
@@ -48,6 +57,7 @@ def test_plugin_default_config(tmp_path: Path) -> None:
"custom_templates": None,
"enable_inventory": None,
"enabled": True,
+ "locale": None,
}
@@ -68,4 +78,5 @@ def test_plugin_config_custom_templates(tmp_path: Path) -> None:
"custom_templates": str(template_dir),
"enable_inventory": None,
"enabled": True,
+ "locale": None,
}
diff --git a/zensical.toml b/zensical.toml
new file mode 100644
index 00000000..d04f4896
--- /dev/null
+++ b/zensical.toml
@@ -0,0 +1,214 @@
+[project]
+site_name = "mkdocstrings"
+site_description = "Automatic documentation from sources, for MkDocs."
+site_author = "Timothée Mazzucotelli"
+site_url = "https://mkdocstrings.github.io/mkdocstrings"
+repo_url = "https://github.com/mkdocstrings/mkdocstrings"
+repo_name = "mkdocstrings/mkdocstrings"
+copyright = "Copyright © 2019 Timothée Mazzucotelli"
+extra_css = ["css/apidocs.css"]
+extra_javascript = ["js/feedback.js"]
+nav = [
+ { "Home" = [
+ { "Overview" = "index.md" },
+ { "Changelog" = "changelog.md" },
+ { "Credits" = "credits.md" },
+ { "License" = "license.md" },
+ ] },
+ { "Usage" = [
+ "usage/index.md",
+ { "Theming" = "usage/theming.md" },
+ { "Handlers" = "usage/handlers.md" },
+ { "All handlers" = [
+ { "C" = "https://mkdocstrings.github.io/c/" },
+ { "Crystal" = "https://mkdocstrings.github.io/crystal/" },
+ { "GitHub Actions" = "https://watermarkhu.nl/mkdocstrings-github/" },
+ { "Python" = "https://mkdocstrings.github.io/python/" },
+ { "Python (Legacy)" = "https://mkdocstrings.github.io/python-legacy/" },
+ { "MATLAB" = "https://watermarkhu.nl/mkdocstrings-matlab/" },
+ { "Shell" = "https://mkdocstrings.github.io/shell/" },
+ { "TypeScript" = "https://mkdocstrings.github.io/typescript/" },
+ { "VBA" = "https://pypi.org/project/mkdocstrings-vba" },
+ ] },
+ { "Guides" = [
+ { "Recipes" = "recipes.md" },
+ { "Troubleshooting" = "troubleshooting.md" },
+ ] },
+ ] },
+ { "API reference" = "reference/api.md" },
+ { "Development" = [
+ { "Contributing" = "contributing.md" },
+ { "Code of Conduct" = "code_of_conduct.md" },
+ ] },
+ { "Author's website" = "https://pawamoy.github.io" },
+]
+
+# ----------------------------------------------------------------------------
+# Theme configuration
+# ----------------------------------------------------------------------------
+[project.theme]
+logo = "logo.svg"
+custom_dir = "docs/.overrides"
+language = "en"
+features = [
+ "announce.dismiss",
+ "content.action.edit",
+ "content.action.view",
+ "content.code.annotate",
+ "content.code.copy",
+ "content.code.select",
+ "content.footnote.tooltips",
+ "content.tabs.link",
+ "content.tooltips",
+ "navigation.footer",
+ "navigation.indexes",
+ "navigation.instant",
+ "navigation.instant.prefetch",
+ "navigation.path",
+ "navigation.sections",
+ "navigation.tabs",
+ "navigation.tabs.sticky",
+ "navigation.top",
+ "search.highlight",
+ "toc.follow",
+]
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme)"
+toggle.icon = "material/brightness-auto"
+toggle.name = "Switch to light mode"
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme: light)"
+scheme = "default"
+primary = "teal"
+accent = "purple"
+toggle.icon = "material/weather-sunny"
+toggle.name = "Switch to dark mode"
+
+[[project.theme.palette]]
+media = "(prefers-color-scheme: dark)"
+scheme = "slate"
+primary = "black"
+accent = "lime"
+toggle.icon = "material/weather-night"
+toggle.name = "Switch to system preference"
+
+[project.theme.icon]
+logo = "material/currency-sign"
+
+# ----------------------------------------------------------------------------
+# Social configuration
+# ----------------------------------------------------------------------------
+[[project.extra.social]]
+icon = "fontawesome/brands/github"
+link = "https://github.com/pawamoy"
+
+[[project.extra.social]]
+icon = "fontawesome/brands/mastodon"
+link = "https://fosstodon.org/@pawamoy"
+
+[[project.extra.social]]
+icon = "fontawesome/brands/twitter"
+link = "https://twitter.com/pawamoy"
+
+[[project.extra.social]]
+icon = "fontawesome/brands/gitter"
+link = "https://gitter.im/mkdocstrings/community"
+
+[[project.extra.social]]
+icon = "fontawesome/brands/python"
+link = "https://pypi.org/project/mkdocstrings/"
+
+[project.extra.analytics.feedback]
+title = "Was this page helpful?"
+
+[[project.extra.analytics.feedback.ratings]]
+icon = "material/emoticon-happy-outline"
+name = "This page was helpful"
+data = 1
+note = "Thank you for your feedback!"
+
+[[project.extra.analytics.feedback.ratings]]
+icon = "material/emoticon-sad-outline"
+name = "This page could be improved"
+data = 0
+note = "Let us know how we can improve this page."
+
+# ----------------------------------------------------------------------------
+# Markdown extensions configuration
+# ----------------------------------------------------------------------------
+[project.markdown_extensions.abbr]
+[project.markdown_extensions.admonition]
+[project.markdown_extensions.attr_list]
+[project.markdown_extensions.callouts]
+[project.markdown_extensions.def_list]
+[project.markdown_extensions.footnotes]
+[project.markdown_extensions.md_in_html]
+[project.markdown_extensions.toc]
+permalink = "¤"
+[project.markdown_extensions.pymdownx.arithmatex]
+generic = true
+[project.markdown_extensions.pymdownx.betterem]
+smart_enable = "all"
+[project.markdown_extensions.pymdownx.caret]
+[project.markdown_extensions.pymdownx.details]
+[project.markdown_extensions.pymdownx.emoji]
+emoji_generator = "zensical.extensions.emoji.to_svg"
+emoji_index = "zensical.extensions.emoji.twemoji"
+[project.markdown_extensions.pymdownx.highlight]
+[project.markdown_extensions.pymdownx.inlinehilite]
+[project.markdown_extensions.pymdownx.keys]
+[project.markdown_extensions.pymdownx.magiclink]
+[project.markdown_extensions.pymdownx.mark]
+[project.markdown_extensions.pymdownx.smartsymbols]
+[project.markdown_extensions.pymdownx.snippets]
+check_paths = true
+[project.markdown_extensions.pymdownx.superfences]
+[[project.markdown_extensions.pymdownx.superfences.custom_fences]]
+name = "python"
+class = "python"
+validator = "markdown_exec.validator"
+format = "markdown_exec.formatter"
+[project.markdown_extensions.pymdownx.tabbed]
+alternate_style = true
+[project.markdown_extensions.pymdownx.tasklist]
+custom_checkbox = true
+[project.markdown_extensions.pymdownx.tilde]
+[project.markdown_extensions.zensical.extensions.preview]
+configurations = [{targets.include = ["reference/api.md"]}]
+
+# ----------------------------------------------------------------------------
+# Plugins configuration
+# ----------------------------------------------------------------------------
+[project.plugins.mkdocstrings.handlers.python]
+paths = ["src"]
+inventories = [
+ "https://docs.python.org/3/objects.inv",
+ "https://installer.readthedocs.io/en/stable/objects.inv", # demonstration purpose in the docs
+ "https://mkdocstrings.github.io/autorefs/objects.inv",
+ "https://www.mkdocs.org/objects.inv",
+ "https://python-markdown.github.io/objects.inv",
+ "https://jinja.palletsprojects.com/en/stable/objects.inv",
+ "https://markupsafe.palletsprojects.com/en/stable/objects.inv",
+]
+
+[project.plugins.mkdocstrings.handlers.python.options]
+backlinks = "tree"
+docstring_options = { "ignore_init_summary" = true }
+docstring_section_style = "list"
+filters = "public"
+heading_level = 1
+inherited_members = true
+merge_init_into_class = true
+parameter_headings = true
+separate_signature = true
+show_root_heading = true
+show_root_full_path = false
+show_signature_annotations = true
+show_source = true
+show_symbol_type_heading = true
+show_symbol_type_toc = true
+signature_crossrefs = true
+summary = true
+unwrap_annotated = true