-
-
Notifications
You must be signed in to change notification settings - Fork 125
Expand file tree
/
Copy pathextension.py
More file actions
493 lines (406 loc) · 18.1 KB
/
extension.py
File metadata and controls
493 lines (406 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
# 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.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 functools import partial
from inspect import signature
from typing import TYPE_CHECKING, Any
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 mkdocs_autorefs import AutorefsConfig, AutorefsExtension, AutorefsPlugin
from mkdocstrings._internal.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers
from mkdocstrings._internal.loggers import get_logger
if TYPE_CHECKING:
from collections.abc import MutableSequence
from markdown import Markdown
_logger = get_logger("mkdocstrings")
class AutoDocProcessor(BlockProcessor):
"""Our "autodoc" Markdown block processor.
It has a [`test` method][mkdocstrings.AutoDocProcessor.test] that tells if a block matches a criterion,
and a [`run` method][mkdocstrings.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<heading>#{1,6} *|)::: ?(?P<name>.+?) *$", 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""