Coverage for src/mkdocstrings_handlers/python/_internal/handler.py: 69.62%
200 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 23:22 +0200
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 23:22 +0200
1# This module implements a handler for the Python language.
3from __future__ import annotations
5import glob
6import os
7import posixpath
8import sys
9from contextlib import suppress
10from dataclasses import asdict
11from pathlib import Path
12from typing import TYPE_CHECKING, Any, BinaryIO, ClassVar
13from warnings import warn
15from griffe import (
16 AliasResolutionError,
17 GriffeLoader,
18 LinesCollection,
19 ModulesCollection,
20 Parser,
21 load_extensions,
22 patch_loggers,
23)
24from mkdocs.exceptions import PluginError
25from mkdocs_autorefs import BacklinkCrumb
26from mkdocstrings import BaseHandler, CollectionError, CollectorItem, HandlerOptions, Inventory, get_logger
28from mkdocstrings_handlers.python._internal import rendering
29from mkdocstrings_handlers.python._internal.config import PythonConfig, PythonOptions
31if TYPE_CHECKING:
32 from collections.abc import Iterable, Iterator, Mapping, MutableMapping, Sequence
34 from mkdocs.config.defaults import MkDocsConfig
35 from mkdocs_autorefs import Backlink
38# YORE: EOL 3.10: Replace block with line 2.
39if sys.version_info >= (3, 11):
40 from contextlib import chdir
41else:
42 from contextlib import contextmanager
44 @contextmanager
45 def chdir(path: str) -> Iterator[None]:
46 old_wd = os.getcwd()
47 os.chdir(path)
48 try:
49 yield
50 finally:
51 os.chdir(old_wd)
54_logger = get_logger(__name__)
56patch_loggers(get_logger)
59# YORE: Bump 2: Remove block.
60def _warn_extra_options(names: Sequence[str]) -> None:
61 warn(
62 "Passing extra options directly under `options` is deprecated. "
63 "Instead, pass them under `options.extra`, and update your templates. "
64 f"Current extra (unrecognized) options: {', '.join(sorted(names))}",
65 DeprecationWarning,
66 stacklevel=3,
67 )
70class PythonHandler(BaseHandler):
71 """The Python handler class."""
73 name: ClassVar[str] = "python"
74 """The handler's name."""
76 domain: ClassVar[str] = "py"
77 """The cross-documentation domain/language for this handler."""
79 enable_inventory: ClassVar[bool] = True
80 """Whether this handler is interested in enabling the creation of the `objects.inv` Sphinx inventory file."""
82 fallback_theme: ClassVar[str] = "material"
83 """The fallback theme."""
85 def __init__(self, config: PythonConfig, base_dir: Path, **kwargs: Any) -> None:
86 """Initialize the handler.
88 Parameters:
89 config: The handler configuration.
90 base_dir: The base directory of the project.
91 **kwargs: Arguments passed to the parent constructor.
92 """
93 super().__init__(**kwargs)
95 self.config = config
96 """The handler configuration."""
97 self.base_dir = base_dir
98 """The base directory of the project."""
100 # YORE: Bump 2: Remove block.
101 global_extra, global_options = PythonOptions._extract_extra(config.options)
102 if global_extra: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 _warn_extra_options(global_extra.keys()) # type: ignore[arg-type]
104 self._global_extra = global_extra
105 self.global_options = global_options
106 """The global configuration options (in `mkdocs.yml`)."""
108 # YORE: Bump 2: Replace `# ` with `` within block.
109 # self.global_options = config.options
110 # """The global configuration options (in `mkdocs.yml`)."""
112 # Warn if user overrides base templates.
113 if self.custom_templates: 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true
114 for theme_dir in base_dir.joinpath(self.custom_templates, "python").iterdir():
115 if theme_dir.joinpath("_base").is_dir():
116 _logger.warning(
117 f"Overriding base template '{theme_dir.name}/_base/<template>.html.jinja' is not supported, "
118 f"override '{theme_dir.name}/<template>.html.jinja' instead",
119 )
121 paths = config.paths or []
123 # Expand paths with glob patterns.
124 with chdir(str(base_dir)):
125 resolved_globs = [glob.glob(path) for path in paths]
126 paths = [path for glob_list in resolved_globs for path in glob_list]
128 # By default, add the base directory to the search paths.
129 if not paths: 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 paths.append(str(base_dir))
132 # Initialize search paths from `sys.path`, eliminating empty paths.
133 search_paths = [path for path in sys.path if path]
135 for path in reversed(paths):
136 # If it's not absolute, make path relative to the config file path, then make it absolute.
137 if not os.path.isabs(path):
138 path = os.path.abspath(base_dir / path) # noqa: PLW2901
139 # Remove pre-listed paths.
140 if path in search_paths:
141 search_paths.remove(path)
142 # Give precedence to user-provided paths.
143 search_paths.insert(0, path)
145 self._paths = search_paths
146 self._modules_collection: ModulesCollection = ModulesCollection()
147 self._lines_collection: LinesCollection = LinesCollection()
149 def get_inventory_urls(self) -> list[tuple[str, dict[str, Any]]]:
150 """Return the URLs of the inventory files to download."""
151 return [(inv.url, inv._config) for inv in self.config.inventories]
153 @staticmethod
154 def load_inventory(
155 in_file: BinaryIO,
156 url: str,
157 base_url: str | None = None,
158 domains: list[str] | None = None,
159 **kwargs: Any, # noqa: ARG004
160 ) -> Iterator[tuple[str, str]]:
161 """Yield items and their URLs from an inventory file streamed from `in_file`.
163 This implements mkdocstrings' `load_inventory` "protocol" (see [`mkdocstrings.plugin`][]).
165 Arguments:
166 in_file: The binary file-like object to read the inventory from.
167 url: The URL that this file is being streamed from (used to guess `base_url`).
168 base_url: The URL that this inventory's sub-paths are relative to.
169 domains: A list of domain strings to filter the inventory by, when not passed, "py" will be used.
170 **kwargs: Ignore additional arguments passed from the config.
172 Yields:
173 Tuples of (item identifier, item URL).
174 """
175 domains = domains or ["py"]
176 if base_url is None: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 base_url = posixpath.dirname(url)
179 for item in Inventory.parse_sphinx(in_file, domain_filter=domains).values(): 179 ↛ exitline 179 didn't return from function 'load_inventory' because the loop on line 179 didn't complete
180 yield item.name, posixpath.join(base_url, item.uri)
182 def get_options(self, local_options: Mapping[str, Any]) -> HandlerOptions:
183 """Get combined default, global and local options.
185 Arguments:
186 local_options: The local options.
188 Returns:
189 The combined options.
190 """
191 # YORE: Bump 2: Remove block.
192 local_extra, local_options = PythonOptions._extract_extra(local_options) # type: ignore[arg-type]
193 if local_extra: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 _warn_extra_options(local_extra.keys()) # type: ignore[arg-type]
195 unknown_extra = self._global_extra | local_extra
197 extra = {**self.global_options.get("extra", {}), **local_options.get("extra", {})}
198 options = {**self.global_options, **local_options, "extra": extra}
199 try:
200 # YORE: Bump 2: Replace `opts =` with `return` within line.
201 opts = PythonOptions.from_data(**options)
202 except Exception as error:
203 raise PluginError(f"Invalid options: {error}") from error
205 # YORE: Bump 2: Remove block.
206 for key, value in unknown_extra.items(): 206 ↛ 207line 206 didn't jump to line 207 because the loop on line 206 never started
207 object.__setattr__(opts, key, value)
208 return opts
210 def collect(self, identifier: str, options: PythonOptions) -> CollectorItem:
211 """Collect the documentation for the given identifier.
213 Parameters:
214 identifier: The identifier of the object to collect.
215 options: The options to use for the collection.
217 Returns:
218 The collected item.
219 """
220 module_name = identifier.split(".", 1)[0]
221 unknown_module = module_name not in self._modules_collection
222 reapply = True
223 if options == {}: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 if unknown_module:
225 raise CollectionError("Not loading additional modules during fallback")
226 options = self.get_options({})
227 reapply = False
229 parser_name = options.docstring_style
230 parser = parser_name and Parser(parser_name)
231 parser_options = options.docstring_options and asdict(options.docstring_options)
233 if unknown_module: 233 ↛ 268line 233 didn't jump to line 268 because the condition on line 233 was always true
234 extensions = self.normalize_extension_paths(options.extensions)
235 loader = GriffeLoader(
236 extensions=load_extensions(*extensions),
237 search_paths=self._paths,
238 docstring_parser=parser,
239 docstring_options=parser_options, # type: ignore[arg-type]
240 modules_collection=self._modules_collection,
241 lines_collection=self._lines_collection,
242 allow_inspection=options.allow_inspection,
243 force_inspection=options.force_inspection,
244 )
245 try:
246 for pre_loaded_module in options.preload_modules: 246 ↛ 247line 246 didn't jump to line 247 because the loop on line 246 never started
247 if pre_loaded_module not in self._modules_collection:
248 loader.load(
249 pre_loaded_module,
250 try_relative_path=False,
251 find_stubs_package=options.find_stubs_package,
252 )
253 loader.load(
254 module_name,
255 try_relative_path=False,
256 find_stubs_package=options.find_stubs_package,
257 )
258 except ImportError as error:
259 raise CollectionError(str(error)) from error
260 unresolved, iterations = loader.resolve_aliases(
261 implicit=False,
262 external=self.config.load_external_modules,
263 )
264 if unresolved: 264 ↛ 265line 264 didn't jump to line 265 because the condition on line 264 was never true
265 _logger.debug(f"{len(unresolved)} aliases were still unresolved after {iterations} iterations")
266 _logger.debug(f"Unresolved aliases: {', '.join(sorted(unresolved))}")
268 try:
269 doc_object = self._modules_collection[identifier]
270 except KeyError as error:
271 raise CollectionError(f"{identifier} could not be found") from error
272 except AliasResolutionError as error:
273 raise CollectionError(str(error)) from error
275 if not unknown_module and reapply: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 with suppress(AliasResolutionError):
277 if doc_object.docstring is not None:
278 doc_object.docstring.parser = parser
279 doc_object.docstring.parser_options = parser_options or {}
281 return doc_object
283 def render(self, data: CollectorItem, options: PythonOptions, locale: str | None = None) -> str:
284 """Render the collected data.
286 Parameters:
287 data: The collected data.
288 options: The options to use for rendering.
289 locale: The locale to use for rendering (default is "en").
291 Returns:
292 The rendered data (HTML).
293 """
294 template_name = rendering.do_get_template(self.env, data)
295 template = self.env.get_template(template_name)
297 return template.render(
298 **{
299 "config": options,
300 data.kind.value: data,
301 # Heading level is a "state" variable, that will change at each step
302 # of the rendering recursion. Therefore, it's easier to use it as a plain value
303 # than as an item in a dictionary.
304 "heading_level": options.heading_level,
305 "root": True,
306 # YORE: Bump 2: Regex-replace ` or .+` with ` or "en",` within line.
307 "locale": locale or self.config.locale,
308 },
309 )
311 def render_backlinks(self, backlinks: Mapping[str, Iterable[Backlink]]) -> str:
312 """Render the backlinks.
314 Parameters:
315 backlinks: The backlinks to render.
317 Returns:
318 The rendered backlinks (HTML).
319 """
320 template = self.env.get_template("backlinks.html.jinja")
321 verbose_type = {key: key.capitalize().replace("-by", " by") for key in backlinks.keys()} # noqa: SIM118
322 return template.render(
323 backlinks=backlinks,
324 config=self.get_options({}),
325 verbose_type=verbose_type,
326 default_crumb=BacklinkCrumb(title="", url=""),
327 )
329 def update_env(self, config: Any) -> None: # noqa: ARG002
330 """Update the Jinja environment with custom filters and tests.
332 Parameters:
333 config: The SSG configuration.
334 """
335 self.env.trim_blocks = True
336 self.env.lstrip_blocks = True
337 self.env.keep_trailing_newline = False
338 self.env.filters["split_path"] = rendering.do_split_path
339 self.env.filters["crossref"] = rendering.do_crossref
340 self.env.filters["multi_crossref"] = rendering.do_multi_crossref
341 self.env.filters["order_members"] = rendering.do_order_members
342 self.env.filters["format_code"] = rendering.do_format_code
343 self.env.filters["format_signature"] = rendering.do_format_signature
344 self.env.filters["format_attribute"] = rendering.do_format_attribute
345 self.env.filters["filter_objects"] = rendering.do_filter_objects
346 self.env.filters["stash_crossref"] = rendering.do_stash_crossref
347 self.env.filters["get_template"] = rendering.do_get_template
348 self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section
349 self.env.filters["as_functions_section"] = rendering.do_as_functions_section
350 self.env.filters["as_classes_section"] = rendering.do_as_classes_section
351 self.env.filters["as_modules_section"] = rendering.do_as_modules_section
352 self.env.filters["backlink_tree"] = rendering.do_backlink_tree
353 self.env.globals["AutorefsHook"] = rendering.AutorefsHook
354 self.env.tests["existing_template"] = lambda template_name: template_name in self.env.list_templates()
356 def get_aliases(self, identifier: str) -> tuple[str, ...]:
357 """Get the aliases for the given identifier.
359 Parameters:
360 identifier: The identifier to get the aliases for.
362 Returns:
363 The aliases.
364 """
365 if "(" in identifier:
366 identifier, parameter = identifier.split("(", 1)
367 parameter.removesuffix(")")
368 else:
369 parameter = ""
370 try:
371 data = self._modules_collection[identifier]
372 except (KeyError, AliasResolutionError):
373 return ()
374 aliases = [data.path]
375 try:
376 for alias in [data.canonical_path, *data.aliases]:
377 if alias not in aliases:
378 aliases.append(alias)
379 except AliasResolutionError:
380 pass
381 if parameter:
382 return tuple(f"{alias}({parameter})" for alias in aliases)
383 return tuple(aliases)
385 def normalize_extension_paths(self, extensions: Sequence) -> list[str | dict[str, Any]]:
386 """Resolve extension paths relative to config file.
388 Parameters:
389 extensions: The extensions (configuration) to normalize.
391 Returns:
392 The normalized extensions.
393 """
394 normalized: list[str | dict[str, Any]] = []
396 for ext in extensions:
397 if isinstance(ext, dict):
398 pth, options = next(iter(ext.items()))
399 pth = str(pth)
400 else:
401 pth = str(ext)
402 options = None
404 if pth.endswith(".py") or ".py:" in pth or "/" in pth or "\\" in pth:
405 # This is a system path. Normalize it, make it absolute relative to config file path.
406 pth = os.path.abspath(self.base_dir / pth)
408 if options is not None:
409 normalized.append({pth: options})
410 else:
411 normalized.append(pth)
413 return normalized
416def get_handler(
417 handler_config: MutableMapping[str, Any],
418 tool_config: MkDocsConfig,
419 **kwargs: Any,
420) -> PythonHandler:
421 """Return an instance of `PythonHandler`.
423 Parameters:
424 handler_config: The handler configuration.
425 tool_config: The tool (SSG) configuration.
426 **kwargs: Additional arguments to pass to the handler.
428 Returns:
429 An instance of `PythonHandler`.
430 """
431 base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent
432 if "inventories" not in handler_config and "import" in handler_config: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true
433 warn("The 'import' key is renamed 'inventories' for the Python handler", FutureWarning, stacklevel=1)
434 handler_config["inventories"] = handler_config.pop("import", [])
435 return PythonHandler(
436 config=PythonConfig.from_data(**handler_config),
437 base_dir=base_dir,
438 **kwargs,
439 )