Coverage for src/mkdocstrings/plugin.py: 58.89%
142 statements
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 18:59 +0200
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 18:59 +0200
1"""This module contains the "mkdocstrings" plugin for MkDocs.
3The plugin instantiates a Markdown extension ([`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]),
4and adds it to the list of Markdown extensions used by `mkdocs`
5during the [`on_config` event hook](https://www.mkdocs.org/user-guide/plugins/#on_config).
7Once the documentation is built, the [`on_post_build` event hook](https://www.mkdocs.org/user-guide/plugins/#on_post_build)
8is triggered and calls the [`handlers.teardown()` method][mkdocstrings.handlers.base.Handlers.teardown]. This method is
9used to teardown the handlers that were instantiated during documentation buildup.
11Finally, when serving the documentation, it can add directories to watch
12during the [`on_serve` event hook](https://www.mkdocs.org/user-guide/plugins/#on_serve).
13"""
15from __future__ import annotations
17import datetime
18import functools
19import os
20import sys
21from collections.abc import Iterable, Mapping
22from concurrent import futures
23from io import BytesIO
24from typing import TYPE_CHECKING, Any, Callable, TypeVar
26from mkdocs.config import Config
27from mkdocs.config import config_options as opt
28from mkdocs.plugins import BasePlugin
29from mkdocs.utils import write_file
30from mkdocs_autorefs.plugin import AutorefsConfig, AutorefsPlugin
32from mkdocstrings._cache import download_and_cache_url, download_url_with_gz
33from mkdocstrings.extension import MkdocstringsExtension
34from mkdocstrings.handlers.base import BaseHandler, Handlers
35from mkdocstrings.loggers import get_logger
37if TYPE_CHECKING:
38 from jinja2.environment import Environment
39 from mkdocs.config.defaults import MkDocsConfig
41if sys.version_info < (3, 10):
42 from typing_extensions import ParamSpec
43else:
44 from typing import ParamSpec
46log = get_logger(__name__)
48InventoryImportType = list[tuple[str, Mapping[str, Any]]]
49InventoryLoaderType = Callable[..., Iterable[tuple[str, str]]]
51P = ParamSpec("P")
52R = TypeVar("R")
55def list_to_tuple(function: Callable[P, R]) -> Callable[P, R]:
56 """Decorater to convert lists to tuples in the arguments."""
58 def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
59 safe_args = [tuple(item) if isinstance(item, list) else item for item in args]
60 if kwargs:
61 kwargs = {key: tuple(value) if isinstance(value, list) else value for key, value in kwargs.items()} # type: ignore[assignment]
62 return function(*safe_args, **kwargs) # type: ignore[arg-type]
64 return wrapper
67class PluginConfig(Config):
68 """The configuration options of `mkdocstrings`, written in `mkdocs.yml`."""
70 handlers = opt.Type(dict, default={})
71 """
72 Global configuration of handlers.
74 You can set global configuration per handler, applied everywhere,
75 but overridable in each "autodoc" instruction. Example:
77 ```yaml
78 plugins:
79 - mkdocstrings:
80 handlers:
81 python:
82 options:
83 option1: true
84 option2: "value"
85 rust:
86 options:
87 option9: 2
88 ```
89 """
91 default_handler = opt.Type(str, default="python")
92 """The default handler to use. The value is the name of the handler module. Default is "python"."""
93 custom_templates = opt.Optional(opt.Dir(exists=True))
94 """Location of custom templates to use when rendering API objects.
96 Value should be the path of a directory relative to the MkDocs configuration file.
97 """
98 enable_inventory = opt.Optional(opt.Type(bool))
99 """Whether to enable object inventory creation."""
100 enabled = opt.Type(bool, default=True)
101 """Whether to enable the plugin. Default is true. If false, *mkdocstrings* will not collect or render anything."""
104class MkdocstringsPlugin(BasePlugin[PluginConfig]):
105 """An `mkdocs` plugin.
107 This plugin defines the following event hooks:
109 - `on_config`
110 - `on_env`
111 - `on_post_build`
113 Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
114 for more information about its plugin system.
115 """
117 css_filename = "assets/_mkdocstrings.css"
119 def __init__(self) -> None:
120 """Initialize the object."""
121 super().__init__()
122 self._handlers: Handlers | None = None
124 @property
125 def handlers(self) -> Handlers:
126 """Get the instance of [mkdocstrings.handlers.base.Handlers][] for this plugin/build.
128 Raises:
129 RuntimeError: If the plugin hasn't been initialized with a config.
131 Returns:
132 An instance of [mkdocstrings.handlers.base.Handlers][] (the same throughout the build).
133 """
134 if not self._handlers: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 raise RuntimeError("The plugin hasn't been initialized with a config yet")
136 return self._handlers
138 def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
139 """Instantiate our Markdown extension.
141 Hook for the [`on_config` event](https://www.mkdocs.org/user-guide/plugins/#on_config).
142 In this hook, we instantiate our [`MkdocstringsExtension`][mkdocstrings.extension.MkdocstringsExtension]
143 and add it to the list of Markdown extensions used by `mkdocs`.
145 We pass this plugin's configuration dictionary to the extension when instantiating it (it will need it
146 later when processing markdown to get handlers and their global configurations).
148 Arguments:
149 config: The MkDocs config object.
151 Returns:
152 The modified config.
153 """
154 if not self.plugin_enabled:
155 log.debug("Plugin is not enabled. Skipping.")
156 return config
157 log.debug("Adding extension to the list")
159 theme_name = config.theme.name or os.path.dirname(config.theme.dirs[0])
161 to_import: InventoryImportType = []
162 for handler_name, conf in self.config.handlers.items(): 162 ↛ 163line 162 didn't jump to line 163 because the loop on line 162 never started
163 for import_item in conf.pop("import", ()):
164 if isinstance(import_item, str):
165 import_item = {"url": import_item} # noqa: PLW2901
166 to_import.append((handler_name, import_item))
168 extension_config = {
169 "theme_name": theme_name,
170 "mdx": config.markdown_extensions,
171 "mdx_configs": config.mdx_configs,
172 "mkdocstrings": self.config,
173 "mkdocs": config,
174 }
175 self._handlers = Handlers(extension_config)
177 autorefs: AutorefsPlugin
178 try:
179 # If autorefs plugin is explicitly enabled, just use it.
180 autorefs = config.plugins["autorefs"] # type: ignore[assignment]
181 log.debug(f"Picked up existing autorefs instance {autorefs!r}")
182 except KeyError:
183 # Otherwise, add a limited instance of it that acts only on what's added through `register_anchor`.
184 autorefs = AutorefsPlugin()
185 autorefs.config = AutorefsConfig()
186 autorefs.scan_toc = False
187 config.plugins["autorefs"] = autorefs
188 log.debug(f"Added a subdued autorefs instance {autorefs!r}")
189 # Add collector-based fallback in either case.
190 autorefs.get_fallback_anchor = self.handlers.get_anchors
192 mkdocstrings_extension = MkdocstringsExtension(extension_config, self.handlers, autorefs)
193 config.markdown_extensions.append(mkdocstrings_extension) # type: ignore[arg-type]
195 config.extra_css.insert(0, self.css_filename) # So that it has lower priority than user files.
197 self._inv_futures = {}
198 if to_import: 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 inv_loader = futures.ThreadPoolExecutor(4)
200 for handler_name, import_item in to_import:
201 loader = self.get_handler(handler_name).load_inventory
202 future = inv_loader.submit(
203 self._load_inventory, # type: ignore[misc]
204 loader,
205 **import_item,
206 )
207 self._inv_futures[future] = (loader, import_item)
208 inv_loader.shutdown(wait=False)
210 return config
212 @property
213 def inventory_enabled(self) -> bool:
214 """Tell if the inventory is enabled or not.
216 Returns:
217 Whether the inventory is enabled.
218 """
219 inventory_enabled = self.config.enable_inventory
220 if inventory_enabled is None:
221 inventory_enabled = any(handler.enable_inventory for handler in self.handlers.seen_handlers)
222 return inventory_enabled
224 @property
225 def plugin_enabled(self) -> bool:
226 """Tell if the plugin is enabled or not.
228 Returns:
229 Whether the plugin is enabled.
230 """
231 return self.config.enabled
233 def on_env(self, env: Environment, config: MkDocsConfig, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
234 """Extra actions that need to happen after all Markdown rendering and before HTML rendering.
236 Hook for the [`on_env` event](https://www.mkdocs.org/user-guide/plugins/#on_env).
238 - Write mkdocstrings' extra files into the site dir.
239 - Gather results from background inventory download tasks.
240 """
241 if not self.plugin_enabled: 241 ↛ 243line 241 didn't jump to line 243 because the condition on line 241 was always true
242 return
243 if self._handlers:
244 css_content = "\n".join(handler.extra_css for handler in self.handlers.seen_handlers)
245 write_file(css_content.encode("utf-8"), os.path.join(config.site_dir, self.css_filename))
247 if self.inventory_enabled:
248 log.debug("Creating inventory file objects.inv")
249 inv_contents = self.handlers.inventory.format_sphinx()
250 write_file(inv_contents, os.path.join(config.site_dir, "objects.inv"))
252 if self._inv_futures:
253 log.debug(f"Waiting for {len(self._inv_futures)} inventory download(s)")
254 futures.wait(self._inv_futures, timeout=30)
255 results = {}
256 # Reversed order so that pages from first futures take precedence:
257 for fut in reversed(list(self._inv_futures)):
258 try:
259 results.update(fut.result())
260 except Exception as error: # noqa: BLE001
261 loader, import_item = self._inv_futures[fut]
262 loader_name = loader.__func__.__qualname__
263 log.error(f"Couldn't load inventory {import_item} through {loader_name}: {error}") # noqa: TRY400
264 for page, identifier in results.items():
265 config.plugins["autorefs"].register_url(page, identifier) # type: ignore[attr-defined]
266 self._inv_futures = {}
268 def on_post_build(
269 self,
270 config: MkDocsConfig, # noqa: ARG002
271 **kwargs: Any, # noqa: ARG002
272 ) -> None:
273 """Teardown the handlers.
275 Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build).
276 This hook is used to teardown all the handlers that were instantiated and cached during documentation buildup.
278 For example, a handler could open a subprocess in the background and keep it open
279 to feed it "autodoc" instructions and get back JSON data. If so, it should then close the subprocess at some point:
280 the proper place to do this is in the handler's `teardown` method, which is indirectly called by this hook.
282 Arguments:
283 config: The MkDocs config object.
284 **kwargs: Additional arguments passed by MkDocs.
285 """
286 if not self.plugin_enabled:
287 return
289 for future in self._inv_futures: 289 ↛ 290line 289 didn't jump to line 290 because the loop on line 289 never started
290 future.cancel()
292 if self._handlers: 292 ↛ exitline 292 didn't return from function 'on_post_build' because the condition on line 292 was always true
293 log.debug("Tearing handlers down")
294 self.handlers.teardown()
296 def get_handler(self, handler_name: str) -> BaseHandler:
297 """Get a handler by its name. See [mkdocstrings.handlers.base.Handlers.get_handler][].
299 Arguments:
300 handler_name: The name of the handler.
302 Returns:
303 An instance of a subclass of [`BaseHandler`][mkdocstrings.handlers.base.BaseHandler].
304 """
305 return self.handlers.get_handler(handler_name)
307 @classmethod
308 # lru_cache does not allow mutable arguments such lists, but that is what we load from YAML config.
309 @list_to_tuple
310 @functools.cache
311 def _load_inventory(cls, loader: InventoryLoaderType, url: str, **kwargs: Any) -> Mapping[str, str]:
312 """Download and process inventory files using a handler.
314 Arguments:
315 loader: A function returning a sequence of pairs (identifier, url).
316 url: The URL to download and process.
317 **kwargs: Extra arguments to pass to the loader.
319 Returns:
320 A mapping from identifier to absolute URL.
321 """
322 log.debug(f"Downloading inventory from {url!r}")
323 content = download_and_cache_url(url, download_url_with_gz, datetime.timedelta(days=1))
324 result = dict(loader(BytesIO(content), url=url, **kwargs))
325 log.debug(f"Loaded inventory from {url!r}: {len(result)} items")
326 return result