Coverage for src/mkdocstrings/handlers/rendering.py: 92.22%
123 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 holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers."""
3from __future__ import annotations
5import copy
6import re
7import textwrap
8from typing import TYPE_CHECKING, Any
10from markdown.extensions import Extension
11from markdown.extensions.codehilite import CodeHiliteExtension
12from markdown.treeprocessors import Treeprocessor
13from markupsafe import Markup
14from pymdownx.highlight import Highlight, HighlightExtension
16if TYPE_CHECKING:
17 from xml.etree.ElementTree import Element
19 from markdown import Markdown
22class Highlighter(Highlight):
23 """Code highlighter that tries to match the Markdown configuration.
25 Picking up the global config and defaults works only if you use the `codehilite` or
26 `pymdownx.highlight` (recommended) Markdown extension.
28 - If you use `pymdownx.highlight`, highlighting settings are picked up from it, and the
29 default CSS class is `.highlight`. This also means the default of `guess_lang: false`.
31 - Otherwise, if you use the `codehilite` extension, settings are picked up from it, and the
32 default CSS class is `.codehilite`. Also consider setting `guess_lang: false`.
34 - If neither are added to `markdown_extensions`, highlighting is enabled anyway. This is for
35 backwards compatibility. If you really want to disable highlighting even in *mkdocstrings*,
36 add one of these extensions anyway and set `use_pygments: false`.
38 The underlying implementation is `pymdownx.highlight` regardless.
39 """
41 # https://raw.githubusercontent.com/facelessuser/pymdown-extensions/main/docs/src/markdown/extensions/highlight.md
42 _highlight_config_keys = frozenset(
43 (
44 "css_class",
45 "guess_lang",
46 "default_lang",
47 "pygments_style",
48 "noclasses",
49 "use_pygments",
50 "linenums",
51 "linenums_special",
52 "linenums_style",
53 "linenums_class",
54 "extend_pygments_lang",
55 "language_prefix",
56 "code_attr_on_pre",
57 "auto_title",
58 "auto_title_map",
59 "line_spans",
60 "anchor_linenums",
61 "line_anchors",
62 "pygments_lang_class",
63 "stripnl",
64 ),
65 )
67 def __init__(self, md: Markdown):
68 """Configure to match a `markdown.Markdown` instance.
70 Arguments:
71 md: The Markdown instance to read configs from.
72 """
73 config: dict[str, Any] = {}
74 self._highlighter: str | None = None
75 for ext in md.registeredExtensions:
76 if isinstance(ext, HighlightExtension) and (ext.enabled or not config):
77 self._highlighter = "highlight"
78 config = ext.getConfigs()
79 break # This one takes priority, no need to continue looking
80 if isinstance(ext, CodeHiliteExtension) and not config:
81 self._highlighter = "codehilite"
82 config = ext.getConfigs()
83 config["language_prefix"] = config["lang_prefix"]
84 self._css_class = config.pop("css_class", "highlight")
85 super().__init__(**{name: opt for name, opt in config.items() if name in self._highlight_config_keys})
87 def highlight(
88 self,
89 src: str,
90 language: str | None = None,
91 *,
92 inline: bool = False,
93 dedent: bool = True,
94 linenums: bool | None = None,
95 **kwargs: Any,
96 ) -> str:
97 """Highlight a code-snippet.
99 Arguments:
100 src: The code to highlight.
101 language: Explicitly tell what language to use for highlighting.
102 inline: Whether to highlight as inline.
103 dedent: Whether to dedent the code before highlighting it or not.
104 linenums: Whether to add line numbers in the result.
105 **kwargs: Pass on to `pymdownx.highlight.Highlight.highlight`.
107 Returns:
108 The highlighted code as HTML text, marked safe (not escaped for HTML).
109 """
110 if isinstance(src, Markup):
111 src = src.unescape()
112 if dedent: 112 ↛ 115line 112 didn't jump to line 115 because the condition on line 112 was always true
113 src = textwrap.dedent(src)
115 kwargs.setdefault("css_class", self._css_class)
116 old_linenums = self.linenums # type: ignore[has-type]
117 if linenums is not None:
118 self.linenums = linenums
119 try:
120 result = super().highlight(src, language, inline=inline, **kwargs)
121 finally:
122 self.linenums = old_linenums
124 if inline:
125 # From the maintainer of codehilite, the codehilite CSS class, as defined by the user,
126 # should never be added to inline code, because codehilite does not support inline code.
127 # See https://github.com/Python-Markdown/markdown/issues/1220#issuecomment-1692160297.
128 css_class = "" if self._highlighter == "codehilite" else kwargs["css_class"]
129 return Markup(f'<code class="{css_class} language-{language}">{result.text}</code>')
130 return Markup(result)
133class IdPrependingTreeprocessor(Treeprocessor):
134 """Prepend the configured prefix to IDs of all HTML elements."""
136 name = "mkdocstrings_ids"
138 id_prefix: str
139 """The prefix to add to every ID. It is prepended without any separator; specify your own separator if needed."""
141 def __init__(self, md: Markdown, id_prefix: str):
142 """Initialize the object.
144 Arguments:
145 md: A `markdown.Markdown` instance.
146 id_prefix: The prefix to add to every ID. It is prepended without any separator.
147 """
148 super().__init__(md)
149 self.id_prefix = id_prefix
151 def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
152 if self.id_prefix: 152 ↛ exitline 152 didn't return from function 'run' because the condition on line 152 was always true
153 self._prefix_ids(root)
155 def _prefix_ids(self, root: Element) -> None:
156 index = len(root)
157 for el in reversed(root): # Reversed mainly for the ability to mutate during iteration.
158 index -= 1
160 self._prefix_ids(el)
161 href_attr = el.get("href")
163 if id_attr := el.get("id"):
164 if el.tag == "a" and not href_attr:
165 # An anchor with id and no href is used by autorefs:
166 # leave it untouched and insert a copy with updated id after it.
167 new_el = copy.deepcopy(el)
168 new_el.set("id", self.id_prefix + id_attr)
169 root.insert(index + 1, new_el)
170 else:
171 # Anchors with id and href are not used by autorefs:
172 # update in place.
173 el.set("id", self.id_prefix + id_attr)
175 # Always update hrefs, names and labels-for:
176 # there will always be a corresponding id.
177 if href_attr and href_attr.startswith("#"):
178 el.set("href", "#" + self.id_prefix + href_attr[1:])
180 if name_attr := el.get("name"): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 el.set("name", self.id_prefix + name_attr)
183 if el.tag == "label": 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true
184 for_attr = el.get("for")
185 if for_attr:
186 el.set("for", self.id_prefix + for_attr)
189class HeadingShiftingTreeprocessor(Treeprocessor):
190 """Shift levels of all Markdown headings according to the configured base level."""
192 name = "mkdocstrings_headings"
193 regex = re.compile(r"([Hh])([1-6])")
195 shift_by: int
196 """The number of heading "levels" to add to every heading. `<h2>` with `shift_by = 3` becomes `<h5>`."""
198 def __init__(self, md: Markdown, shift_by: int):
199 """Initialize the object.
201 Arguments:
202 md: A `markdown.Markdown` instance.
203 shift_by: The number of heading "levels" to add to every heading.
204 """
205 super().__init__(md)
206 self.shift_by = shift_by
208 def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
209 if not self.shift_by:
210 return
211 for el in root.iter():
212 match = self.regex.fullmatch(el.tag)
213 if match:
214 level = int(match[2]) + self.shift_by
215 level = max(1, min(level, 6))
216 el.tag = f"{match[1]}{level}"
219class _HeadingReportingTreeprocessor(Treeprocessor):
220 """Records the heading elements encountered in the document."""
222 name = "mkdocstrings_headings_list"
223 regex = re.compile(r"[Hh][1-6]")
225 headings: list[Element]
226 """The list (the one passed in the initializer) that is used to record the heading elements (by appending to it)."""
228 def __init__(self, md: Markdown, headings: list[Element]):
229 super().__init__(md)
230 self.headings = headings
232 def run(self, root: Element) -> None:
233 permalink_class = self.md.treeprocessors["toc"].permalink_class # type: ignore[attr-defined]
234 for el in root.iter():
235 if self.regex.fullmatch(el.tag):
236 el = copy.copy(el) # noqa: PLW2901
237 # 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML.
238 # Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension.
239 if len(el) > 0 and el[-1].get("class") == permalink_class:
240 del el[-1]
241 self.headings.append(el)
244class ParagraphStrippingTreeprocessor(Treeprocessor):
245 """Unwraps the <p> element around the whole output."""
247 name = "mkdocstrings_strip_paragraph"
248 strip = False
250 def run(self, root: Element) -> Element | None: # noqa: D102 (ignore missing docstring)
251 if self.strip and len(root) == 1 and root[0].tag == "p": 251 ↛ 253line 251 didn't jump to line 253 because the condition on line 251 was never true
252 # Turn the single <p> element into the root element and inherit its tag name (it's significant!)
253 root[0].tag = root.tag
254 return root[0]
255 return None
258class MkdocstringsInnerExtension(Extension):
259 """Extension that should always be added to Markdown sub-documents that handlers request (and *only* them)."""
261 def __init__(self, headings: list[Element]):
262 """Initialize the object.
264 Arguments:
265 headings: A list that will be populated with all HTML heading elements encountered in the document.
266 """
267 super().__init__()
268 self.headings = headings
270 def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
271 """Register the extension.
273 Arguments:
274 md: A `markdown.Markdown` instance.
275 """
276 md.registerExtension(self)
277 md.treeprocessors.register(
278 HeadingShiftingTreeprocessor(md, 0),
279 HeadingShiftingTreeprocessor.name,
280 priority=12,
281 )
282 md.treeprocessors.register(
283 IdPrependingTreeprocessor(md, ""),
284 IdPrependingTreeprocessor.name,
285 priority=4, # Right after 'toc' (needed because that extension adds ids to headers).
286 )
287 md.treeprocessors.register(
288 _HeadingReportingTreeprocessor(md, self.headings),
289 _HeadingReportingTreeprocessor.name,
290 priority=1, # Close to the end.
291 )
292 md.treeprocessors.register(
293 ParagraphStrippingTreeprocessor(md),
294 ParagraphStrippingTreeprocessor.name,
295 priority=0.99, # Close to the end.
296 )