Coverage for src/mkdocstrings/handlers/rendering.py: 91.30%
117 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 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 "pygments_style",
47 "noclasses",
48 "use_pygments",
49 "linenums",
50 "linenums_special",
51 "linenums_style",
52 "linenums_class",
53 "extend_pygments_lang",
54 "language_prefix",
55 "code_attr_on_pre",
56 "auto_title",
57 "auto_title_map",
58 "line_spans",
59 "anchor_linenums",
60 "line_anchors",
61 ),
62 )
64 def __init__(self, md: Markdown):
65 """Configure to match a `markdown.Markdown` instance.
67 Arguments:
68 md: The Markdown instance to read configs from.
69 """
70 config: dict[str, Any] = {}
71 self._highlighter: str | None = None
72 for ext in md.registeredExtensions:
73 if isinstance(ext, HighlightExtension) and (ext.enabled or not config):
74 self._highlighter = "highlight"
75 config = ext.getConfigs()
76 break # This one takes priority, no need to continue looking
77 if isinstance(ext, CodeHiliteExtension) and not config:
78 self._highlighter = "codehilite"
79 config = ext.getConfigs()
80 config["language_prefix"] = config["lang_prefix"]
81 self._css_class = config.pop("css_class", "highlight")
82 super().__init__(**{name: opt for name, opt in config.items() if name in self._highlight_config_keys})
84 def highlight(
85 self,
86 src: str,
87 language: str | None = None,
88 *,
89 inline: bool = False,
90 dedent: bool = True,
91 linenums: bool | None = None,
92 **kwargs: Any,
93 ) -> str:
94 """Highlight a code-snippet.
96 Arguments:
97 src: The code to highlight.
98 language: Explicitly tell what language to use for highlighting.
99 inline: Whether to highlight as inline.
100 dedent: Whether to dedent the code before highlighting it or not.
101 linenums: Whether to add line numbers in the result.
102 **kwargs: Pass on to `pymdownx.highlight.Highlight.highlight`.
104 Returns:
105 The highlighted code as HTML text, marked safe (not escaped for HTML).
106 """
107 if isinstance(src, Markup):
108 src = src.unescape()
109 if dedent: 109 ↛ 112line 109 didn't jump to line 112, because the condition on line 109 was never false
110 src = textwrap.dedent(src)
112 kwargs.setdefault("css_class", self._css_class)
113 old_linenums = self.linenums # type: ignore[has-type]
114 if linenums is not None:
115 self.linenums = linenums
116 try:
117 result = super().highlight(src, language, inline=inline, **kwargs)
118 finally:
119 self.linenums = old_linenums
121 if inline:
122 # From the maintainer of codehilite, the codehilite CSS class, as defined by the user,
123 # should never be added to inline code, because codehilite does not support inline code.
124 # See https://github.com/Python-Markdown/markdown/issues/1220#issuecomment-1692160297.
125 css_class = "" if self._highlighter == "codehilite" else kwargs["css_class"]
126 return Markup(f'<code class="{css_class} language-{language}">{result.text}</code>')
127 return Markup(result)
130class IdPrependingTreeprocessor(Treeprocessor):
131 """Prepend the configured prefix to IDs of all HTML elements."""
133 name = "mkdocstrings_ids"
135 id_prefix: str
136 """The prefix to add to every ID. It is prepended without any separator; specify your own separator if needed."""
138 def __init__(self, md: Markdown, id_prefix: str):
139 """Initialize the object.
141 Arguments:
142 md: A `markdown.Markdown` instance.
143 id_prefix: The prefix to add to every ID. It is prepended without any separator.
144 """
145 super().__init__(md)
146 self.id_prefix = id_prefix
148 def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
149 if not self.id_prefix: 149 ↛ 150line 149 didn't jump to line 150, because the condition on line 149 was never true
150 return
151 for el in root.iter():
152 id_attr = el.get("id")
153 if id_attr:
154 el.set("id", self.id_prefix + id_attr)
156 href_attr = el.get("href")
157 if href_attr and href_attr.startswith("#"):
158 el.set("href", "#" + self.id_prefix + href_attr[1:])
160 name_attr = el.get("name")
161 if name_attr: 161 ↛ 162line 161 didn't jump to line 162, because the condition on line 161 was never true
162 el.set("name", self.id_prefix + name_attr)
164 if el.tag == "label": 164 ↛ 165line 164 didn't jump to line 165, because the condition on line 164 was never true
165 for_attr = el.get("for")
166 if for_attr:
167 el.set("for", self.id_prefix + for_attr)
170class HeadingShiftingTreeprocessor(Treeprocessor):
171 """Shift levels of all Markdown headings according to the configured base level."""
173 name = "mkdocstrings_headings"
174 regex = re.compile(r"([Hh])([1-6])")
176 shift_by: int
177 """The number of heading "levels" to add to every heading. `<h2>` with `shift_by = 3` becomes `<h5>`."""
179 def __init__(self, md: Markdown, shift_by: int):
180 """Initialize the object.
182 Arguments:
183 md: A `markdown.Markdown` instance.
184 shift_by: The number of heading "levels" to add to every heading.
185 """
186 super().__init__(md)
187 self.shift_by = shift_by
189 def run(self, root: Element) -> None: # noqa: D102 (ignore missing docstring)
190 if not self.shift_by:
191 return
192 for el in root.iter():
193 match = self.regex.fullmatch(el.tag)
194 if match:
195 level = int(match[2]) + self.shift_by
196 level = max(1, min(level, 6))
197 el.tag = f"{match[1]}{level}"
200class _HeadingReportingTreeprocessor(Treeprocessor):
201 """Records the heading elements encountered in the document."""
203 name = "mkdocstrings_headings_list"
204 regex = re.compile(r"[Hh][1-6]")
206 headings: list[Element]
207 """The list (the one passed in the initializer) that is used to record the heading elements (by appending to it)."""
209 def __init__(self, md: Markdown, headings: list[Element]):
210 super().__init__(md)
211 self.headings = headings
213 def run(self, root: Element) -> None:
214 permalink_class = self.md.treeprocessors["toc"].permalink_class # type: ignore[attr-defined]
215 for el in root.iter():
216 if self.regex.fullmatch(el.tag):
217 el = copy.copy(el) # noqa: PLW2901
218 # 'toc' extension's first pass (which we require to build heading stubs/ids) also edits the HTML.
219 # Undo the permalink edit so we can pass this heading to the outer pass of the 'toc' extension.
220 if len(el) > 0 and el[-1].get("class") == permalink_class:
221 del el[-1]
222 self.headings.append(el)
225class ParagraphStrippingTreeprocessor(Treeprocessor):
226 """Unwraps the <p> element around the whole output."""
228 name = "mkdocstrings_strip_paragraph"
229 strip = False
231 def run(self, root: Element) -> Element | None: # noqa: D102 (ignore missing docstring)
232 if self.strip and len(root) == 1 and root[0].tag == "p": 232 ↛ 234line 232 didn't jump to line 234, because the condition on line 232 was never true
233 # Turn the single <p> element into the root element and inherit its tag name (it's significant!)
234 root[0].tag = root.tag
235 return root[0]
236 return None
239class MkdocstringsInnerExtension(Extension):
240 """Extension that should always be added to Markdown sub-documents that handlers request (and *only* them)."""
242 def __init__(self, headings: list[Element]):
243 """Initialize the object.
245 Arguments:
246 headings: A list that will be populated with all HTML heading elements encountered in the document.
247 """
248 super().__init__()
249 self.headings = headings
251 def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name)
252 """Register the extension.
254 Arguments:
255 md: A `markdown.Markdown` instance.
256 """
257 md.registerExtension(self)
258 md.treeprocessors.register(
259 HeadingShiftingTreeprocessor(md, 0),
260 HeadingShiftingTreeprocessor.name,
261 priority=12,
262 )
263 md.treeprocessors.register(
264 IdPrependingTreeprocessor(md, ""),
265 IdPrependingTreeprocessor.name,
266 priority=4, # Right after 'toc' (needed because that extension adds ids to headers).
267 )
268 md.treeprocessors.register(
269 _HeadingReportingTreeprocessor(md, self.headings),
270 _HeadingReportingTreeprocessor.name,
271 priority=1, # Close to the end.
272 )
273 md.treeprocessors.register(
274 ParagraphStrippingTreeprocessor(md),
275 ParagraphStrippingTreeprocessor.name,
276 priority=0.99, # Close to the end.
277 )