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

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

63 

64 def __init__(self, md: Markdown): 

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

66 

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

83 

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. 

95 

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

103 

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) 

111 

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 

120 

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) 

128 

129 

130class IdPrependingTreeprocessor(Treeprocessor): 

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

132 

133 name = "mkdocstrings_ids" 

134 

135 id_prefix: str 

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

137 

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

139 """Initialize the object. 

140 

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 

147 

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) 

155 

156 href_attr = el.get("href") 

157 if href_attr and href_attr.startswith("#"): 

158 el.set("href", "#" + self.id_prefix + href_attr[1:]) 

159 

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) 

163 

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) 

168 

169 

170class HeadingShiftingTreeprocessor(Treeprocessor): 

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

172 

173 name = "mkdocstrings_headings" 

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

175 

176 shift_by: int 

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

178 

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

180 """Initialize the object. 

181 

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 

188 

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

198 

199 

200class _HeadingReportingTreeprocessor(Treeprocessor): 

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

202 

203 name = "mkdocstrings_headings_list" 

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

205 

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

208 

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

210 super().__init__(md) 

211 self.headings = headings 

212 

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) 

223 

224 

225class ParagraphStrippingTreeprocessor(Treeprocessor): 

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

227 

228 name = "mkdocstrings_strip_paragraph" 

229 strip = False 

230 

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 

237 

238 

239class MkdocstringsInnerExtension(Extension): 

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

241 

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

243 """Initialize the object. 

244 

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 

250 

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

252 """Register the extension. 

253 

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 )