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

1"""This module holds helpers responsible for augmentations to the Markdown sub-documents produced by handlers.""" 

2 

3from __future__ import annotations 

4 

5import copy 

6import re 

7import textwrap 

8from typing import TYPE_CHECKING, Any 

9 

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 

15 

16if TYPE_CHECKING: 

17 from xml.etree.ElementTree import Element 

18 

19 from markdown import Markdown 

20 

21 

22class Highlighter(Highlight): 

23 """Code highlighter that tries to match the Markdown configuration. 

24 

25 Picking up the global config and defaults works only if you use the `codehilite` or 

26 `pymdownx.highlight` (recommended) Markdown extension. 

27 

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`. 

30 

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`. 

33 

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`. 

37 

38 The underlying implementation is `pymdownx.highlight` regardless. 

39 """ 

40 

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 ) 

66 

67 def __init__(self, md: Markdown): 

68 """Configure to match a `markdown.Markdown` instance. 

69 

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}) 

86 

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. 

98 

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`. 

106 

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) 

114 

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 

123 

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) 

131 

132 

133class IdPrependingTreeprocessor(Treeprocessor): 

134 """Prepend the configured prefix to IDs of all HTML elements.""" 

135 

136 name = "mkdocstrings_ids" 

137 

138 id_prefix: str 

139 """The prefix to add to every ID. It is prepended without any separator; specify your own separator if needed.""" 

140 

141 def __init__(self, md: Markdown, id_prefix: str): 

142 """Initialize the object. 

143 

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 

150 

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) 

154 

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 

159 

160 self._prefix_ids(el) 

161 href_attr = el.get("href") 

162 

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) 

174 

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:]) 

179 

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) 

182 

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) 

187 

188 

189class HeadingShiftingTreeprocessor(Treeprocessor): 

190 """Shift levels of all Markdown headings according to the configured base level.""" 

191 

192 name = "mkdocstrings_headings" 

193 regex = re.compile(r"([Hh])([1-6])") 

194 

195 shift_by: int 

196 """The number of heading "levels" to add to every heading. `<h2>` with `shift_by = 3` becomes `<h5>`.""" 

197 

198 def __init__(self, md: Markdown, shift_by: int): 

199 """Initialize the object. 

200 

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 

207 

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}" 

217 

218 

219class _HeadingReportingTreeprocessor(Treeprocessor): 

220 """Records the heading elements encountered in the document.""" 

221 

222 name = "mkdocstrings_headings_list" 

223 regex = re.compile(r"[Hh][1-6]") 

224 

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).""" 

227 

228 def __init__(self, md: Markdown, headings: list[Element]): 

229 super().__init__(md) 

230 self.headings = headings 

231 

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) 

242 

243 

244class ParagraphStrippingTreeprocessor(Treeprocessor): 

245 """Unwraps the <p> element around the whole output.""" 

246 

247 name = "mkdocstrings_strip_paragraph" 

248 strip = False 

249 

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 

256 

257 

258class MkdocstringsInnerExtension(Extension): 

259 """Extension that should always be added to Markdown sub-documents that handlers request (and *only* them).""" 

260 

261 def __init__(self, headings: list[Element]): 

262 """Initialize the object. 

263 

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 

269 

270 def extendMarkdown(self, md: Markdown) -> None: # noqa: N802 (casing: parent method's name) 

271 """Register the extension. 

272 

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 )