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

1"""This module holds the code of the Markdown extension responsible for matching "autodoc" instructions. 

2 

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

5 

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. 

8 

9Both the collection and rendering process can be configured by adding YAML configuration under the "autodoc" 

10instruction: 

11 

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

23 

24from __future__ import annotations 

25 

26import re 

27from collections import ChainMap 

28from typing import TYPE_CHECKING, Any, MutableSequence 

29from xml.etree.ElementTree import Element 

30 

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 

37 

38from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem, Handlers 

39from mkdocstrings.loggers import get_logger 

40 

41if TYPE_CHECKING: 

42 from markdown import Markdown 

43 from markdown.blockparser import BlockParser 

44 from mkdocs_autorefs.plugin import AutorefsPlugin 

45 

46 

47log = get_logger(__name__) 

48 

49 

50class AutoDocProcessor(BlockProcessor): 

51 """Our "autodoc" Markdown block processor. 

52 

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. 

55 

56 It also has utility methods allowing to get handlers and their configuration easily, useful when processing 

57 a matched block. 

58 """ 

59 

60 regex = re.compile(r"^(?P<heading>#{1,6} *|)::: ?(?P<name>.+?) *$", flags=re.MULTILINE) 

61 

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. 

71 

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

85 

86 def test(self, parent: Element, block: str) -> bool: # noqa: ARG002 

87 """Match our autodoc instructions. 

88 

89 Arguments: 

90 parent: The parent element in the XML tree. 

91 block: The block to be tested. 

92 

93 Returns: 

94 Whether this block should be processed or not. 

95 """ 

96 return bool(self.regex.search(block)) 

97 

98 def run(self, parent: Element, blocks: MutableSequence[str]) -> None: 

99 """Run code on the matched blocks. 

100 

101 The identifier and configuration lines are retrieved from a matched block 

102 and used to collect and render an object. 

103 

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) 

110 

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

116 

117 block, the_rest = self.detab(block) 

118 

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

123 

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) 

131 

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) 

137 

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 ) 

146 

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 ) 

161 

162 parent.append(el) 

163 

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) 

169 

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. 

177 

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

182 

183 Raises: 

184 PluginError: When something wrong happened during collection. 

185 TemplateNotFound: When a template used for rendering could not be found. 

186 

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) 

192 

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) 

196 

197 global_options = handler_config.get("options", {}) 

198 local_options = config.get("options", {}) 

199 options = ChainMap(local_options, global_options) 

200 

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 

203 

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 

212 

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) 

217 

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 

227 

228 return rendered, handler, data 

229 

230 

231class _PostProcessor(Treeprocessor): 

232 def run(self, root: Element) -> None: 

233 self._remove_duplicated_headings(root) 

234 

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) 

247 

248 if carry_text: 

249 parent.text = (parent.text or "") + carry_text 

250 

251 

252class MkdocstringsExtension(Extension): 

253 """Our Markdown extension. 

254 

255 It cannot work outside of `mkdocstrings`. 

256 """ 

257 

258 def __init__(self, config: dict, handlers: Handlers, autorefs: AutorefsPlugin, **kwargs: Any) -> None: 

259 """Initialize the object. 

260 

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 

272 

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

274 """Register the extension. 

275 

276 Add an instance of our [`AutoDocProcessor`][mkdocstrings.extension.AutoDocProcessor] to the Markdown parser. 

277 

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 )