Belle II Software development
basf2ext.py
1
8
9"""
10This module is a Sphinx Extension for the Belle~II Software:
11
12* add a domain "b2" for modules and variables.
13 Modules can be documented using the ``.. b2:module::` directive and variables
14 using the ``.. b2:variable::` directive. They can be cross referenced with
15 :b2:mod: and :b2:var: respectively
16* add an index for basf2 modules
17* add a directive to automatically document basf2 modules similar to autodoc
18
19 .. b2-modules::
20 :package: framework
21 :modules: EventInfoSetter, EventInfoPrinter
22 :library: libcore.so
23 :regex-filter: Event.*
24
25* add directive to automatically document basf2 variables
26
27 .. b2-variables::
28 :group: Kinematics
29 :variables: x, y, z
30 :regex-filter: .*
31
32"""
33
34
35import os
36import re
37import textwrap
38from docutils import nodes
39from sphinx.util.nodes import nested_parse_with_titles
40from docutils.parsers.rst import Directive, directives
41from docutils.statemachine import StringList
42from basf2domain import Basf2Domain
43from basf2 import list_available_modules, register_module
44from sphinx.domains.std import StandardDomain
45import xml.etree.ElementTree as ET
46
47
48def parse_with_titles(state, content):
49 """Shortcut function to parse a reStructuredText fragment into docutils nodes"""
50 node = nodes.container()
51 # copied from autodoc: necessary so that the child nodes get the right source/line set
52 node.document = state.document
53 if isinstance(content, str):
54 content = content.splitlines()
55 if not isinstance(content, StringList):
56 content = StringList(content)
57 nested_parse_with_titles(state, content, node)
58 return node.children
59
60
61class RenderDocstring(Directive):
62 """Directive to Render Docstring as Docutils nodes.
63 This is useful as standard reStructuredText does not parse Google
64 Docstrings but we support it in docstrings for python functions, modules
65 and variables. So to show example docstrings in the documentation we don't
66 want to write the example in Google Docstring and keep that synchronous
67 with a reStructuredText version
68 """
69
70 has_content = True
71 option_spec = {
72 "lines": directives.unchanged
73 }
74
75
76 def run(self):
77 """Just pass on the content to the autodoc-process-docstring event and
78 then parse the resulting reStructuredText."""
79 env = self.state.document.settings.env
80 content = list(self.content)
81 try:
82 start_index, end_index = (int(e) for e in self.options.get("lines", None).split(","))
83 content = content[start_index:end_index]
84 except Exception:
85 pass
86
87 # remove common whitespace
88 content = textwrap.dedent("\n".join(content)).splitlines()
89
90 env.app.emit('autodoc-process-docstring', "docstring", None, None, None, content)
91 return parse_with_titles(self.state, content)
92
93
94
95class ModuleListDirective(Directive):
96 has_content = False
97 option_spec = {
98 "library": directives.unchanged,
99 "modules": directives.unchanged,
100 "package": directives.unchanged,
101 "no-parameters": directives.flag,
102 "noindex": directives.flag,
103 "regex-filter": directives.unchanged,
104 "io-plots": directives.flag,
105 }
106
107
108 def show_module(self, module, library):
109 description = module.description().splitlines()
110 for i, line in enumerate(description):
111 description[i] = line.replace('|release|', self.state.document.settings.env.app.config.release)
112 # pretend to be the autodoc extension to let other events process
113 # the doc string. Enables Google/Numpy docstrings as well as a bit
114 # of doxygen docstring conversion we have
115 env = self.state.document.settings.env
116 env.app.emit('autodoc-process-docstring', "b2:module", module.name(), module, None, description)
117 description += ["", "",
118 f":Package: {module.package()}",
119 f":Library: {os.path.basename(library)}",
120 ]
121
122 if "no-parameters" not in self.options:
123 optional_params = []
124 required_params = []
125 for p in module.available_params():
126 dest = required_params if p.forceInSteering else optional_params
127 default = "" if p.forceInSteering else f", default={p.default!r}"
128 param_desc = p.description.splitlines()
129 # run the description through autodoc event to get
130 # Google/Numpy/doxygen style as well
131 env.app.emit('autodoc-process-docstring', 'b2:module:param', module.name() + '.' + p.name, p, None, param_desc)
132 param_desc = textwrap.indent("\n".join(param_desc), 8 * " ").splitlines()
133 dest += [f" * **{p.name}** *({p.type}{default})*"]
134 dest += param_desc
135
136 if (required_params):
137 description += [":Required Parameters:", " "] + required_params
138 if (optional_params):
139 description += [":Parameters:", " "] + optional_params
140
141 if "io-plots" in self.options:
142 image = f"build/ioplots/{module.name()}.png"
143 if os.path.exists(image):
144 description += [":IO diagram:", " ", f" .. image:: /{image}"]
145
146 content = [f".. b2:module:: {module.name()}"] + self.noindex + [" "]
147 content += [" " + e for e in description]
148 return parse_with_titles(self.state, content)
149
150 def run(self):
151 all_modules = list_available_modules().items()
152 # check if we have a list of modules to show if so filter the list of
153 # all modules
154 if "modules" in self.options:
155 modules = [e.strip() for e in self.options["modules"].split(",")]
156 all_modules = [e for e in all_modules if e[0] in modules]
157
158 # check if we have a regex-filter, if so filter list of modules
159 if "regex-filter" in self.options:
160 re_filter = re.compile(self.options["regex-filter"])
161 all_modules = [e for e in all_modules if re_filter.match(e[0]) is not None]
162
163 # aaand also filter by library (in case some one wants to document all
164 # modules found in a given library)
165 if "library" in self.options:
166 lib = self.options["library"].strip()
167 all_modules = [e for e in all_modules if os.path.basename(e[1]) == lib]
168
169 # see if we have to forward noindex
170 self.noindex = [" :noindex:"] if "noindex" in self.options else []
171
172 # list of all docutil nodes we create to be returned
173 all_nodes = []
174
175 # now loop over all modules
176 for name, library in sorted(all_modules):
177 module = register_module(name)
178 # filter by package: can only be done after instantiating the module
179 if "package" in self.options and module.package() != self.options["package"]:
180 continue
181
182 # everyting set, create documentation for our module
183 all_nodes += self.show_module(module, library)
184
185 return all_nodes
186
187
188def doxygen_file_page(filename: str) -> str:
189 base, ext = os.path.splitext(os.path.basename(filename))
190 if ext.startswith("."):
191 ext = ext[1:]
192 return f"{base}_8{ext}_source.html"
193
194
195def doxygen_source_link(location_elem):
196 """
197 Given a <location> element from Doxygen XML,
198 build html page name and anchor
199 """
200 file_path = location_elem.attrib['file']
201 line = int(location_elem.attrib['line'])
202
203 page = doxygen_file_page(file_path)
204 anchor = f"#l{line:05d}"
205
206 return page + anchor
207
208
209def build_doxygen_anchor_map(xml_dir):
210 """
211 Parse Doxygen XML files and build mapping:
212 qualifiedname -> "File_8ext_source.html#lNNNNN"
213 If no <location> element is available, fall back to anchorfile#anchor.
214 xml_dir: path to doxygen/xml (must contain index.xml)
215 Returns: dict { "VarName": "classFoo_1_1Bar.html#a1234abcd" }
216 """
217 anchors = {}
218
219 # parse index to know what compound XMLs to check
220 index_path = os.path.join(xml_dir, "index.xml")
221 if not os.path.exists(index_path):
222 return anchors
223 tree = ET.parse(index_path)
224 root = tree.getroot()
225
226 for comp in root.findall("compound"):
227 refid = comp.attrib["refid"]
228 compound_file = os.path.join(xml_dir, f"{refid}.xml")
229 if not os.path.exists(compound_file):
230 continue
231
232 ctree = ET.parse(compound_file)
233 croot = ctree.getroot()
234
235 for member in croot.findall(".//memberdef"):
236 kind = member.attrib.get("kind")
237 if kind not in ("function", "variable"): # limit to things we care about
238 continue
239
240 name = member.findtext("name")
241 qualified = member.findtext("qualifiedname")
242 if qualified and not qualified.startswith("Belle2::Variable::"):
243 continue
244 key = qualified or name
245
246 href = None
247 location = member.find("location")
248
249 # If a location exists, build "_source.html#lNNNNN" link
250 if location is not None and "file" in location.attrib and "line" in location.attrib:
251 href = doxygen_source_link(location)
252
253 # Otherwise fall back to anchorfile + anchor
254 if href is None:
255 anchor_file = member.findtext("anchorfile")
256 anchor_id = member.findtext("anchor")
257 if anchor_file and anchor_id:
258 href = f"{anchor_file}#{anchor_id}"
259
260 # Skip members that don't look like real variables (avoid REGISTER_VARIABLE macro noise)
261 definition = member.findtext("definition") or ""
262 if definition.startswith("REGISTER_VARIABLE"):
263 continue
264
265 if href:
266 # Prefer header declarations/class members, but overwrite if key unseen
267 if key not in anchors:
268 anchors[key] = href
269 else:
270 # Heuristic: prefer .cc_source.html links over generic anchor ones
271 if "_source.html" in href and "_source.html" not in anchors[key]:
272 anchors[key] = href
273
274 return anchors
275
276
277
278class VariableListDirective(Directive):
279 has_content = False
280 option_spec = {
281 "group": directives.unchanged,
282 "variables": directives.unchanged,
283 "regex-filter": directives.unchanged,
284 "description-regex-filter": directives.unchanged,
285 "noindex": directives.flag,
286 "filename": directives.unchanged,
287 }
288
289
290 def run(self):
291 from ROOT import Belle2
293 self.noindex = [" :noindex:"] if "noindex" in self.options else []
294 all_variables = []
295 explicit_list = None
296 regex_filter = None
297 desc_regex_filter = None
298 if "variables" in self.options:
299 explicit_list = [e.strip() for e in self.options["variables"].split(",")]
300 if "regex-filter" in self.options:
301 regex_filter = re.compile(self.options["regex-filter"])
302 if "description-regex-filter" in self.options:
303 desc_regex_filter = re.compile(self.options["description-regex-filter"])
304
305 for var in manager.getVariables():
306 if "group" in self.options and self.options["group"] != var.group:
307 continue
308 if explicit_list and var.name not in explicit_list:
309 continue
310 if regex_filter and not regex_filter.match(var.name):
311 continue
312 if desc_regex_filter and not desc_regex_filter.match(str(var.description)):
313 continue
314 all_variables.append(var)
315
316 all_nodes = []
317 env = self.state.document.settings.env
318 baseurl = env.app.config.basf2_doxygen_baseurl
319 anchor_map = getattr(env.app.env, "b2_anchor_map", {})
320 for var in sorted(all_variables, key=lambda x: x.name):
321
322 # for overloaded variables, we might have to flag noindex in the
323 # variable description so also check for that
324 index = self.noindex
325 desc = str(var.description)
326 desc = textwrap.dedent(desc).strip("\n")
327 if ":noindex:" in var.description:
328 index = [" :noindex:"]
329 desc = desc.replace(":noindex:", "")
330
331 docstring = desc.splitlines()
332 # pretend to be the autodoc extension to let other events process
333 # the doc string. Enables Google/Numpy docstrings as well as a bit
334 # of doxygen docstring conversion we have
335 env.app.emit('autodoc-process-docstring', "b2:variable", var.name, var, None, docstring)
336
337 description = [f".. b2:variable:: {var.name}"] + index + [""]
338 qualifiedName = f"Belle2::Variable::{var.name}"
339 qualifiedFunctionName = f"Belle2::Variable::{var.functionName}"
340 key = qualifiedName if qualifiedName in anchor_map else qualifiedFunctionName
341 if key in anchor_map:
342 link = f"{baseurl}{anchor_map[key]}"
343 description.insert(1, f" :source: {link}")
344 description += [" " + e for e in docstring]
345 if "group" not in self.options:
346 description += ["", f" :Group: {var.group}"]
347
348 all_nodes += parse_with_titles(self.state, description)
349
350 return all_nodes
351
352
353def html_page_context(app, pagename, templatename, context, doctree):
354 """Provide Link to GitLab Repository, see https://mg.pov.lt/blog/sphinx-edit-on-github.html
355
356 this goes in conjunction with
357 site_scons/sphinx/_sphinxtemplates/sourcelink.html and adds a link to our
358 git repository instead to the local source link
359 """
360
361 if templatename != 'page.html' or not doctree:
362 return
363
364 path = os.path.relpath(doctree.get('source'), app.builder.srcdir)
365 repository = app.config.basf2_repository
366 if not repository:
367 return
368
369 commit = app.config.basf2_commitid
370 context["source_url"] = f"{repository}/-/blob/main/{path}"
371 if commit:
372 context["source_url"] = f"{repository}/-/blob/{commit}/{path}"
373
374
375def gitlab_issue_role(role, rawtext, text, lineno, inliner, options=None, content=None):
376 if content is None:
377 content = []
378 if options is None:
379 options = {}
380 issue_url = inliner.document.settings.env.app.config.basf2_issues
381 if not issue_url:
382 return [nodes.literal(rawtext, text=text, language=None)], []
383
384 url = f"{issue_url}/{text}"
385 return [nodes.reference(rawtext, text=text, refuri=url)], []
386
387
388def doxygen_role(role, rawtext, text, lineno, inliner, options=None, content=None):
389 if content is None:
390 content = []
391 if options is None:
392 options = {}
393 release_version = inliner.document.settings.env.app.config.release
394 match = re.match(r'(.+?)<(.+?)>', text)
395 if match:
396 display_text, url_text = match.groups()
397 else:
398 display_text = text
399 url_text = text
400 if "html#" in url_text:
401 url = f"https://software.belle2.org/{release_version}/doxygen/{url_text}"
402 else:
403 url = f"https://software.belle2.org/{release_version}/doxygen/{url_text}.html"
404
405 return [nodes.reference(rawsource=rawtext, text=display_text, refuri=url)], []
406
407
408def sphinx_role(role, rawtext, text, lineno, inliner, options=None, content=None):
409 if content is None:
410 content = []
411 if options is None:
412 options = {}
413 release_version = inliner.document.settings.env.app.config.release
414 match = re.match(r'(.+?)<(.+?)>', text)
415 if match:
416 display_text, url_text = match.groups()
417 else:
418 display_text = text
419 url_text = text
420 if "html#" in url_text:
421 url = f"https://software.belle2.org/{release_version}/sphinx/{url_text}"
422 else:
423 url = f"https://software.belle2.org/{release_version}/sphinx/{url_text}.html"
424
425 return [nodes.reference(rawsource=rawtext, text=display_text, refuri=url)], []
426
427
428def load_doxygen_anchors(app):
429 xml_dir = app.config.basf2_doxygen_xml_dir
430 app.env.b2_anchor_map = build_doxygen_anchor_map(xml_dir)
431
432
433def setup(app):
434 import basf2
435 basf2.logging.log_level = basf2.LogLevel.WARNING
436
437 app.add_config_value("basf2_repository", "", True)
438 app.add_config_value("basf2_commitid", "", True)
439 app.add_config_value("basf2_issues", "", True)
440 app.add_config_value("basf2_doxygen_baseurl", "", "env")
441 app.add_config_value("basf2_doxygen_xml_dir", None, "env")
442 app.add_domain(Basf2Domain)
443 app.add_directive("b2-modules", ModuleListDirective)
444 app.add_directive("b2-variables", VariableListDirective)
445 app.add_directive("docstring", RenderDocstring)
446 app.add_role("issue", gitlab_issue_role)
447 app.add_role("doxygen", doxygen_role)
448 app.add_role("sphinx", sphinx_role)
449 app.connect('html-page-context', html_page_context)
450 app.connect("builder-inited", load_doxygen_anchors)
451
452 # Sadly sphinx does not seem to add labels to custom indices ... :/
453 StandardDomain.initial_data["labels"]["b2-modindex"] = ("b2-modindex", "", "basf2 Module Index")
454 StandardDomain.initial_data["labels"]["b2-varindex"] = ("b2-varindex", "", "basf2 Variable Index")
455 StandardDomain.initial_data["anonlabels"]["b2-modindex"] = ("b2-modindex", "")
456 StandardDomain.initial_data["anonlabels"]["b2-varindex"] = ("b2-varindex", "")
457 return {'version': 0.3}
static Manager & Instance()
get singleton instance.
Definition Manager.cc:26