Coverage for src/mkdocstrings/extension.py: 86.30%
114 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-14 19:41 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-14 19:41 +0100
1"""This module holds the code of the Markdown extension responsible for matching "autodoc" instructions.
3The extension is composed of a Markdown [block processor](https://python-markdown.github.io/extensions/api/#blockparser)
4that matches indented blocks starting with a line like '::: identifier'.
6For each of these blocks, it uses a [handler][mkdocstrings.handlers.base.BaseHandler] to collect documentation about
7the given identifier and render it with Jinja templates.
9Both the collection and rendering process can be configured by adding YAML configuration under the "autodoc"
10instruction:
12```yaml
13::: some.identifier
14 handler: python
15 options:
16 option1: value1
17 option2:
18 - value2a
19 - value2b
20 option_x: etc
21```
22"""
24from __future__ import annotations
26import re
27from collections import ChainMap
28from typing import TYPE_CHECKING, Any, MutableSequence
29from xml.etree.ElementTree import Element
31import yaml
32from jinja2.exceptions import TemplateNotFound
33from markdown.blockprocessors import BlockProcessor
34from markdown.extensions import Extension
35from markdown.treeprocessors import Treeprocessor
36from mkdocs.exceptions import PluginError
38from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers
39from mkdocstrings.loggers import get_logger
41if TYPE_CHECKING:
42 from markdown import Markdown
43 from markdown.blockparser import BlockParser
44 from mkdocs_autorefs.plugin import AutorefsPlugin
47log = get_logger(__name__)
50class AutoDocProcessor(BlockProcessor):
51 """Our "autodoc" Markdown block processor.
53 It has a [`test` method][mkdocstrings.extension.AutoDocProcessor.test] that tells if a block matches a criterion,
54 and a [`run` method][mkdocstrings.extension.AutoDocProcessor.run] that processes it.
56 It also has utility methods allowing to get handlers and their configuration easily, useful when processing
57 a matched block.
58 """
60 regex = re.compile(r"^(?P<heading>#{1,6} *|)::: ?(?P<name>.+?) *$", flags=re.MULTILINE)
62 def __init__(
63 self,
64 parser: BlockParser,
65 md: Markdown,
66 config: dict,
67 handlers: Handlers,
68 autorefs: AutorefsPlugin,
69 ) -> None:
70 """Initialize the object.
72 Arguments:
73 parser: A `markdown.blockparser.BlockParser` instance.
74 md: A `markdown.Markdown` instance.
75 config: The [configuration][mkdocstrings.plugin.PluginConfig] of the `mkdocstrings` plugin.
76 handlers: The handlers container.
77 autorefs: The autorefs plugin instance.
78 """
79 super().__init__(parser=parser)
80 self.md = md
81 self._config = config
82 self._handlers = handlers
83 self._autorefs = autorefs
84 self._updated_envs: set = set()
86 def test(self, parent: Element, block: str) -> bool: # noqa: ARG002
87 """Match our autodoc instructions.
89 Arguments:
90 parent: The parent element in the XML tree.
91 block: The block to be tested.
93 Returns:
94 Whether this block should be processed or not.
95 """
96 return bool(self.regex.search(block))
98 def run(self, parent: Element, blocks: MutableSequence[str]) -> None:
99 """Run code on the matched blocks.
101 The identifier and configuration lines are retrieved from a matched block
102 and used to collect and render an object.
104 Arguments:
105 parent: The parent element in the XML tree.
106 blocks: The rest of the blocks to be processed.
107 """
108 block = blocks.pop(0)
109 match = self.regex.search(block)
111 if match: 111 ↛ 117line 111 didn't jump to line 117, because the condition on line 111 was never false
112 if match.start() > 0:
113 self.parser.parseBlocks(parent, [block[: match.start()]])
114 # removes the first line
115 block = block[match.end() :]
117 block, the_rest = self.detab(block)
119 if match: 119 ↛ 164line 119 didn't jump to line 164, because the condition on line 119 was never false
120 identifier = match["name"]
121 heading_level = match["heading"].count("#")
122 log.debug(f"Matched '::: {identifier}'")
124 html, handler, data = self._process_block(identifier, block, heading_level)
125 el = Element("div", {"class": "mkdocstrings"})
126 # The final HTML is inserted as opaque to subsequent processing, and only revealed at the end.
127 el.text = self.md.htmlStash.store(html)
128 # So we need to duplicate the headings directly (and delete later), just so 'toc' can pick them up.
129 headings = handler.get_headings()
130 el.extend(headings)
132 page = self._autorefs.current_page
133 if page is not None:
134 for heading in headings:
135 rendered_anchor = heading.attrib["id"]
136 self._autorefs.register_anchor(page, rendered_anchor)
138 if "data-role" in heading.attrib: 138 ↛ 134line 138 didn't jump to line 134, because the condition on line 138 was never false
139 self._handlers.inventory.register(
140 name=rendered_anchor,
141 domain=handler.domain,
142 role=heading.attrib["data-role"],
143 priority=1, # register with standard priority
144 uri=f"{page}#{rendered_anchor}",
145 )
147 # also register other anchors for this object in the inventory
148 try:
149 data_object = handler.collect(rendered_anchor, handler.fallback_config)
150 except CollectionError:
151 continue
152 for anchor in handler.get_anchors(data_object):
153 if anchor not in self._handlers.inventory:
154 self._handlers.inventory.register(
155 name=anchor,
156 domain=handler.domain,
157 role=heading.attrib["data-role"],
158 priority=2, # register with lower priority
159 uri=f"{page}#{rendered_anchor}",
160 )
162 parent.append(el)
164 if the_rest: 164 ↛ 168line 164 didn't jump to line 168, because the condition on line 164 was never true
165 # This block contained unindented line(s) after the first indented
166 # line. Insert these lines as the first block of the master blocks
167 # list for future processing.
168 blocks.insert(0, the_rest)
170 def _process_block(
171 self,
172 identifier: str,
173 yaml_block: str,
174 heading_level: int = 0,
175 ) -> tuple[str, BaseHandler, CollectorItem]:
176 """Process an autodoc block.
178 Arguments:
179 identifier: The identifier of the object to collect and render.
180 yaml_block: The YAML configuration.
181 heading_level: Suggested level of the heading to insert (0 to ignore).
183 Raises:
184 PluginError: When something wrong happened during collection.
185 TemplateNotFound: When a template used for rendering could not be found.
187 Returns:
188 Rendered HTML, the handler that was used, and the collected item.
189 """
190 config = yaml.safe_load(yaml_block) or {}
191 handler_name = self._handlers.get_handler_name(config)
193 log.debug(f"Using handler '{handler_name}'")
194 handler_config = self._handlers.get_handler_config(handler_name)
195 handler = self._handlers.get_handler(handler_name, handler_config)
197 global_options = handler_config.get("options", {})
198 local_options = config.get("options", {})
199 options = ChainMap(local_options, global_options)
201 if heading_level: 201 ↛ 202line 201 didn't jump to line 202, because the condition on line 201 was never true
202 options = ChainMap(options, {"heading_level": heading_level}) # like setdefault
204 log.debug("Collecting data")
205 try:
206 data: CollectorItem = handler.collect(identifier, options)
207 except CollectionError as exception:
208 log.error(str(exception)) # noqa: TRY400
209 if PluginError is SystemExit: # TODO: when MkDocs 1.2 is sufficiently common, this can be dropped.
210 log.error(f"Error reading page '{self._autorefs.current_page}':") # noqa: TRY400
211 raise PluginError(f"Could not collect '{identifier}'") from exception
213 if handler_name not in self._updated_envs: # We haven't seen this handler before on this document.
214 log.debug("Updating handler's rendering env")
215 handler._update_env(self.md, self._config)
216 self._updated_envs.add(handler_name)
218 log.debug("Rendering templates")
219 try:
220 rendered = handler.render(data, options)
221 except TemplateNotFound as exc:
222 theme_name = self._config["theme_name"]
223 log.error( # noqa: TRY400
224 f"Template '{exc.name}' not found for '{handler_name}' handler and theme '{theme_name}'.",
225 )
226 raise
228 return rendered, handler, data
231class _PostProcessor(Treeprocessor):
232 def run(self, root: Element) -> None:
233 self._remove_duplicated_headings(root)
235 def _remove_duplicated_headings(self, parent: Element) -> None:
236 carry_text = ""
237 for el in reversed(parent): # Reversed mainly for the ability to mutate during iteration.
238 if el.tag == "div" and el.get("class") == "mkdocstrings":
239 # Delete the duplicated headings along with their container, but keep the text (i.e. the actual HTML).
240 carry_text = (el.text or "") + carry_text
241 parent.remove(el)
242 else:
243 if carry_text:
244 el.tail = (el.tail or "") + carry_text
245 carry_text = ""
246 self._remove_duplicated_headings(el)
248 if carry_text:
249 parent.text = (parent.text or "") + carry_text
252class MkdocstringsExtension(Extension):
253 """Our Markdown extension.
255 It cannot work outside of `mkdocstrings`.
256 """
258 def __init__(self, config: dict, handlers: Handlers, autorefs: AutorefsPlugin, **kwargs: Any) -> None:
259 """Initialize the object.
261 Arguments:
262 config: The configuration items from `mkdocs` and `mkdocstrings` that must be passed to the block processor
263 when instantiated in [`extendMarkdown`][mkdocstrings.extension.MkdocstringsExtension.extendMarkdown].
264 handlers: The handlers container.
265 autorefs: The autorefs plugin instance.
266 **kwargs: Keyword arguments used by `markdown.extensions.Extension`.
267 """
268 super().__init__(**kwargs)
269 self._config = config
270 self._handlers = handlers
271 self._autorefs = autorefs
273 def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
274 """Register the extension.
276 Add an instance of our [`AutoDocProcessor`][mkdocstrings.extension.AutoDocProcessor] to the Markdown parser.
278 Arguments:
279 md: A `markdown.Markdown` instance.
280 """
281 md.parser.blockprocessors.register(
282 AutoDocProcessor(md.parser, md, self._config, self._handlers, self._autorefs),
283 "mkdocstrings",
284 priority=75, # Right before markdown.blockprocessors.HashHeaderProcessor
285 )
286 md.treeprocessors.register(
287 _PostProcessor(md),
288 "mkdocstrings_post",
289 priority=4, # Right after 'toc'.
290 )