diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 17:25:40 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 17:25:40 +0000 |
commit | cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a (patch) | |
tree | 18dcde1a8d1f5570a77cd0c361de3b490d02c789 /sphinx/environment/collectors | |
parent | Initial commit. (diff) | |
download | sphinx-be21195fae81d3ed2bf307cc2df9ad6779da83b0.tar.xz sphinx-be21195fae81d3ed2bf307cc2df9ad6779da83b0.zip |
Adding upstream version 7.2.6.upstream/7.2.6
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'sphinx/environment/collectors')
-rw-r--r-- | sphinx/environment/collectors/__init__.py | 72 | ||||
-rw-r--r-- | sphinx/environment/collectors/asset.py | 147 | ||||
-rw-r--r-- | sphinx/environment/collectors/dependencies.py | 57 | ||||
-rw-r--r-- | sphinx/environment/collectors/metadata.py | 70 | ||||
-rw-r--r-- | sphinx/environment/collectors/title.py | 61 | ||||
-rw-r--r-- | sphinx/environment/collectors/toctree.py | 355 |
6 files changed, 762 insertions, 0 deletions
diff --git a/sphinx/environment/collectors/__init__.py b/sphinx/environment/collectors/__init__.py new file mode 100644 index 0000000..c7e069a --- /dev/null +++ b/sphinx/environment/collectors/__init__.py @@ -0,0 +1,72 @@ +"""The data collector components for sphinx.environment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from docutils import nodes + + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +class EnvironmentCollector: + """An EnvironmentCollector is a specific data collector from each document. + + It gathers data and stores :py:class:`BuildEnvironment + <sphinx.environment.BuildEnvironment>` as a database. Examples of specific + data would be images, download files, section titles, metadatas, index + entries and toctrees, etc. + """ + + listener_ids: dict[str, int] | None = None + + def enable(self, app: Sphinx) -> None: + assert self.listener_ids is None + self.listener_ids = { + 'doctree-read': app.connect('doctree-read', self.process_doc), + 'env-merge-info': app.connect('env-merge-info', self.merge_other), + 'env-purge-doc': app.connect('env-purge-doc', self.clear_doc), + 'env-get-updated': app.connect('env-get-updated', self.get_updated_docs), + 'env-get-outdated': app.connect('env-get-outdated', self.get_outdated_docs), + } + + def disable(self, app: Sphinx) -> None: + assert self.listener_ids is not None + for listener_id in self.listener_ids.values(): + app.disconnect(listener_id) + self.listener_ids = None + + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + """Remove specified data of a document. + + This method is called on the removal of the document.""" + raise NotImplementedError + + def merge_other(self, app: Sphinx, env: BuildEnvironment, + docnames: set[str], other: BuildEnvironment) -> None: + """Merge in specified data regarding docnames from a different `BuildEnvironment` + object which coming from a subprocess in parallel builds.""" + raise NotImplementedError + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Process a document and gather specific data from it. + + This method is called after the document is read.""" + raise NotImplementedError + + def get_updated_docs(self, app: Sphinx, env: BuildEnvironment) -> list[str]: + """Return a list of docnames to re-read. + + This methods is called after reading the whole of documents (experimental). + """ + return [] + + def get_outdated_docs(self, app: Sphinx, env: BuildEnvironment, + added: set[str], changed: set[str], removed: set[str]) -> list[str]: + """Return a list of docnames to re-read. + + This methods is called before reading the documents. + """ + return [] diff --git a/sphinx/environment/collectors/asset.py b/sphinx/environment/collectors/asset.py new file mode 100644 index 0000000..c2066f4 --- /dev/null +++ b/sphinx/environment/collectors/asset.py @@ -0,0 +1,147 @@ +"""The image collector for sphinx.environment.""" + +from __future__ import annotations + +import os +from glob import glob +from os import path +from typing import TYPE_CHECKING, Any + +from docutils import nodes +from docutils.utils import relative_path + +from sphinx import addnodes +from sphinx.environment.collectors import EnvironmentCollector +from sphinx.locale import __ +from sphinx.util import logging +from sphinx.util.i18n import get_image_filename_for_language, search_image_for_language +from sphinx.util.images import guess_mimetype + +if TYPE_CHECKING: + from docutils.nodes import Node + + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + +logger = logging.getLogger(__name__) + + +class ImageCollector(EnvironmentCollector): + """Image files collector for sphinx.environment.""" + + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + env.images.purge_doc(docname) + + def merge_other(self, app: Sphinx, env: BuildEnvironment, + docnames: set[str], other: BuildEnvironment) -> None: + env.images.merge_other(docnames, other.images) + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Process and rewrite image URIs.""" + docname = app.env.docname + + for node in doctree.findall(nodes.image): + # Map the mimetype to the corresponding image. The writer may + # choose the best image from these candidates. The special key * is + # set if there is only single candidate to be used by a writer. + # The special key ? is set for nonlocal URIs. + candidates: dict[str, str] = {} + node['candidates'] = candidates + imguri = node['uri'] + if imguri.startswith('data:'): + candidates['?'] = imguri + continue + if imguri.find('://') != -1: + candidates['?'] = imguri + continue + + if imguri.endswith(os.extsep + '*'): + # Update `node['uri']` to a relative path from srcdir + # from a relative path from current document. + rel_imgpath, full_imgpath = app.env.relfn2path(imguri, docname) + node['uri'] = rel_imgpath + + # Search language-specific figures at first + i18n_imguri = get_image_filename_for_language(imguri, app.env) + _, full_i18n_imgpath = app.env.relfn2path(i18n_imguri, docname) + self.collect_candidates(app.env, full_i18n_imgpath, candidates, node) + + self.collect_candidates(app.env, full_imgpath, candidates, node) + else: + # substitute imguri by figure_language_filename + # (ex. foo.png -> foo.en.png) + imguri = search_image_for_language(imguri, app.env) + + # Update `node['uri']` to a relative path from srcdir + # from a relative path from current document. + original_uri = node['uri'] + node['uri'], _ = app.env.relfn2path(imguri, docname) + candidates['*'] = node['uri'] + if node['uri'] != original_uri: + node['original_uri'] = original_uri + + # map image paths to unique image names (so that they can be put + # into a single directory) + for imgpath in candidates.values(): + app.env.dependencies[docname].add(imgpath) + if not os.access(path.join(app.srcdir, imgpath), os.R_OK): + logger.warning(__('image file not readable: %s') % imgpath, + location=node, type='image', subtype='not_readable') + continue + app.env.images.add_file(docname, imgpath) + + def collect_candidates(self, env: BuildEnvironment, imgpath: str, + candidates: dict[str, str], node: Node) -> None: + globbed: dict[str, list[str]] = {} + for filename in glob(imgpath): + new_imgpath = relative_path(path.join(env.srcdir, 'dummy'), + filename) + try: + mimetype = guess_mimetype(filename) + if mimetype is None: + basename, suffix = path.splitext(filename) + mimetype = 'image/x-' + suffix[1:] + if mimetype not in candidates: + globbed.setdefault(mimetype, []).append(new_imgpath) + except OSError as err: + logger.warning(__('image file %s not readable: %s') % (filename, err), + location=node, type='image', subtype='not_readable') + for key, files in globbed.items(): + candidates[key] = sorted(files, key=len)[0] # select by similarity + + +class DownloadFileCollector(EnvironmentCollector): + """Download files collector for sphinx.environment.""" + + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + env.dlfiles.purge_doc(docname) + + def merge_other(self, app: Sphinx, env: BuildEnvironment, + docnames: set[str], other: BuildEnvironment) -> None: + env.dlfiles.merge_other(docnames, other.dlfiles) + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Process downloadable file paths. """ + for node in doctree.findall(addnodes.download_reference): + targetname = node['reftarget'] + if '://' in targetname: + node['refuri'] = targetname + else: + rel_filename, filename = app.env.relfn2path(targetname, app.env.docname) + app.env.dependencies[app.env.docname].add(rel_filename) + if not os.access(filename, os.R_OK): + logger.warning(__('download file not readable: %s') % filename, + location=node, type='download', subtype='not_readable') + continue + node['filename'] = app.env.dlfiles.add_file(app.env.docname, rel_filename) + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_env_collector(ImageCollector) + app.add_env_collector(DownloadFileCollector) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/environment/collectors/dependencies.py b/sphinx/environment/collectors/dependencies.py new file mode 100644 index 0000000..df1f0c1 --- /dev/null +++ b/sphinx/environment/collectors/dependencies.py @@ -0,0 +1,57 @@ +"""The dependencies collector components for sphinx.environment.""" + +from __future__ import annotations + +import os +from os import path +from typing import TYPE_CHECKING, Any + +from docutils.utils import relative_path + +from sphinx.environment.collectors import EnvironmentCollector +from sphinx.util.osutil import fs_encoding + +if TYPE_CHECKING: + from docutils import nodes + + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +class DependenciesCollector(EnvironmentCollector): + """dependencies collector for sphinx.environment.""" + + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + env.dependencies.pop(docname, None) + + def merge_other(self, app: Sphinx, env: BuildEnvironment, + docnames: set[str], other: BuildEnvironment) -> None: + for docname in docnames: + if docname in other.dependencies: + env.dependencies[docname] = other.dependencies[docname] + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Process docutils-generated dependency info.""" + cwd = os.getcwd() + frompath = path.join(path.normpath(app.srcdir), 'dummy') + deps = doctree.settings.record_dependencies + if not deps: + return + for dep in deps.list: + # the dependency path is relative to the working dir, so get + # one relative to the srcdir + if isinstance(dep, bytes): + dep = dep.decode(fs_encoding) + relpath = relative_path(frompath, + path.normpath(path.join(cwd, dep))) + app.env.dependencies[app.env.docname].add(relpath) + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_env_collector(DependenciesCollector) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/environment/collectors/metadata.py b/sphinx/environment/collectors/metadata.py new file mode 100644 index 0000000..5f737a9 --- /dev/null +++ b/sphinx/environment/collectors/metadata.py @@ -0,0 +1,70 @@ +"""The metadata collector components for sphinx.environment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from docutils import nodes + +from sphinx.environment.collectors import EnvironmentCollector + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +class MetadataCollector(EnvironmentCollector): + """metadata collector for sphinx.environment.""" + + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + env.metadata.pop(docname, None) + + def merge_other(self, app: Sphinx, env: BuildEnvironment, + docnames: set[str], other: BuildEnvironment) -> None: + for docname in docnames: + env.metadata[docname] = other.metadata[docname] + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Process the docinfo part of the doctree as metadata. + + Keep processing minimal -- just return what docutils says. + """ + index = doctree.first_child_not_matching_class(nodes.PreBibliographic) + if index is None: + return + elif isinstance(doctree[index], nodes.docinfo): + md = app.env.metadata[app.env.docname] + for node in doctree[index]: # type: ignore[attr-defined] + # nodes are multiply inherited... + if isinstance(node, nodes.authors): + authors = cast(list[nodes.author], node) + md['authors'] = [author.astext() for author in authors] + elif isinstance(node, nodes.field): + assert len(node) == 2 + field_name = cast(nodes.field_name, node[0]) + field_body = cast(nodes.field_body, node[1]) + md[field_name.astext()] = field_body.astext() + elif isinstance(node, nodes.TextElement): + # other children must be TextElement + # see: https://docutils.sourceforge.io/docs/ref/doctree.html#bibliographic-elements # noqa: E501 + md[node.__class__.__name__] = node.astext() + + for name, value in md.items(): + if name in ('tocdepth',): + try: + value = int(value) + except ValueError: + value = 0 + md[name] = value + + doctree.pop(index) + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_env_collector(MetadataCollector) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/environment/collectors/title.py b/sphinx/environment/collectors/title.py new file mode 100644 index 0000000..014d77a --- /dev/null +++ b/sphinx/environment/collectors/title.py @@ -0,0 +1,61 @@ +"""The title collector components for sphinx.environment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from docutils import nodes + +from sphinx.environment.collectors import EnvironmentCollector +from sphinx.transforms import SphinxContentsFilter + +if TYPE_CHECKING: + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + + +class TitleCollector(EnvironmentCollector): + """title collector for sphinx.environment.""" + + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + env.titles.pop(docname, None) + env.longtitles.pop(docname, None) + + def merge_other(self, app: Sphinx, env: BuildEnvironment, + docnames: set[str], other: BuildEnvironment) -> None: + for docname in docnames: + env.titles[docname] = other.titles[docname] + env.longtitles[docname] = other.longtitles[docname] + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Add a title node to the document (just copy the first section title), + and store that title in the environment. + """ + titlenode = nodes.title() + longtitlenode = titlenode + # explicit title set with title directive; use this only for + # the <title> tag in HTML output + if 'title' in doctree: + longtitlenode = nodes.title() + longtitlenode += nodes.Text(doctree['title']) + # look for first section title and use that as the title + for node in doctree.findall(nodes.section): + visitor = SphinxContentsFilter(doctree) + node[0].walkabout(visitor) + titlenode += visitor.get_entry_text() + break + else: + # document has no title + titlenode += nodes.Text(doctree.get('title', '<no title>')) + app.env.titles[app.env.docname] = titlenode + app.env.longtitles[app.env.docname] = longtitlenode + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_env_collector(TitleCollector) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/sphinx/environment/collectors/toctree.py b/sphinx/environment/collectors/toctree.py new file mode 100644 index 0000000..772591e --- /dev/null +++ b/sphinx/environment/collectors/toctree.py @@ -0,0 +1,355 @@ +"""Toctree collector for sphinx.environment.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from docutils import nodes + +from sphinx import addnodes +from sphinx.environment.adapters.toctree import note_toctree +from sphinx.environment.collectors import EnvironmentCollector +from sphinx.locale import __ +from sphinx.transforms import SphinxContentsFilter +from sphinx.util import logging, url_re + +if TYPE_CHECKING: + from collections.abc import Sequence + + from docutils.nodes import Element, Node + + from sphinx.application import Sphinx + from sphinx.environment import BuildEnvironment + +N = TypeVar('N') + +logger = logging.getLogger(__name__) + + +class TocTreeCollector(EnvironmentCollector): + def clear_doc(self, app: Sphinx, env: BuildEnvironment, docname: str) -> None: + env.tocs.pop(docname, None) + env.toc_secnumbers.pop(docname, None) + env.toc_fignumbers.pop(docname, None) + env.toc_num_entries.pop(docname, None) + env.toctree_includes.pop(docname, None) + env.glob_toctrees.discard(docname) + env.numbered_toctrees.discard(docname) + + for subfn, fnset in list(env.files_to_rebuild.items()): + fnset.discard(docname) + if not fnset: + del env.files_to_rebuild[subfn] + + def merge_other(self, app: Sphinx, env: BuildEnvironment, docnames: set[str], + other: BuildEnvironment) -> None: + for docname in docnames: + env.tocs[docname] = other.tocs[docname] + env.toc_num_entries[docname] = other.toc_num_entries[docname] + if docname in other.toctree_includes: + env.toctree_includes[docname] = other.toctree_includes[docname] + if docname in other.glob_toctrees: + env.glob_toctrees.add(docname) + if docname in other.numbered_toctrees: + env.numbered_toctrees.add(docname) + + for subfn, fnset in other.files_to_rebuild.items(): + env.files_to_rebuild.setdefault(subfn, set()).update(fnset & set(docnames)) + + def process_doc(self, app: Sphinx, doctree: nodes.document) -> None: + """Build a TOC from the doctree and store it in the inventory.""" + docname = app.env.docname + numentries = [0] # nonlocal again... + + def build_toc( + node: Element | Sequence[Element], + depth: int = 1, + ) -> nodes.bullet_list | None: + # list of table of contents entries + entries: list[Element] = [] + # cache of parents -> list item + memo_parents: dict[tuple[str, ...], nodes.list_item] = {} + for sectionnode in node: + # find all toctree nodes in this section and add them + # to the toc (just copying the toctree node which is then + # resolved in self.get_and_resolve_doctree) + if isinstance(sectionnode, nodes.section): + title = sectionnode[0] + # copy the contents of the section title, but without references + # and unnecessary stuff + visitor = SphinxContentsFilter(doctree) + title.walkabout(visitor) + nodetext = visitor.get_entry_text() + anchorname = _make_anchor_name(sectionnode['ids'], numentries) + # make these nodes: + # list_item -> compact_paragraph -> reference + reference = nodes.reference( + '', '', internal=True, refuri=docname, + anchorname=anchorname, *nodetext) + para = addnodes.compact_paragraph('', '', reference) + item: Element = nodes.list_item('', para) + sub_item = build_toc(sectionnode, depth + 1) + if sub_item: + item += sub_item + entries.append(item) + # Wrap items under an ``.. only::`` directive in a node for + # post-processing + elif isinstance(sectionnode, addnodes.only): + onlynode = addnodes.only(expr=sectionnode['expr']) + blist = build_toc(sectionnode, depth) + if blist: + onlynode += blist.children + entries.append(onlynode) + # check within the section for other node types + elif isinstance(sectionnode, nodes.Element): + toctreenode: nodes.Node + for toctreenode in sectionnode.findall(): + if isinstance(toctreenode, nodes.section): + continue + if isinstance(toctreenode, addnodes.toctree): + item = toctreenode.copy() + entries.append(item) + # important: do the inventory stuff + note_toctree(app.env, docname, toctreenode) + # add object signatures within a section to the ToC + elif isinstance(toctreenode, addnodes.desc): + for sig_node in toctreenode: + if not isinstance(sig_node, addnodes.desc_signature): + continue + # Skip if no name set + if not sig_node.get('_toc_name', ''): + continue + # Skip if explicitly disabled + if sig_node.parent.get('no-contents-entry'): + continue + # Skip entries with no ID (e.g. with :no-index: set) + ids = sig_node['ids'] + if not ids: + continue + + anchorname = _make_anchor_name(ids, numentries) + + reference = nodes.reference( + '', '', nodes.literal('', sig_node['_toc_name']), + internal=True, refuri=docname, anchorname=anchorname) + para = addnodes.compact_paragraph('', '', reference, + skip_section_number=True) + entry = nodes.list_item('', para) + *parents, _ = sig_node['_toc_parts'] + parents = tuple(parents) + + # Cache parents tuple + memo_parents[sig_node['_toc_parts']] = entry + + # Nest children within parents + if parents and parents in memo_parents: + root_entry = memo_parents[parents] + if isinstance(root_entry[-1], nodes.bullet_list): + root_entry[-1].append(entry) + else: + root_entry.append(nodes.bullet_list('', entry)) + continue + + entries.append(entry) + + if entries: + return nodes.bullet_list('', *entries) + return None + + toc = build_toc(doctree) + if toc: + app.env.tocs[docname] = toc + else: + app.env.tocs[docname] = nodes.bullet_list('') + app.env.toc_num_entries[docname] = numentries[0] + + def get_updated_docs(self, app: Sphinx, env: BuildEnvironment) -> list[str]: + return self.assign_section_numbers(env) + self.assign_figure_numbers(env) + + def assign_section_numbers(self, env: BuildEnvironment) -> list[str]: + """Assign a section number to each heading under a numbered toctree.""" + # a list of all docnames whose section numbers changed + rewrite_needed = [] + + assigned: set[str] = set() + old_secnumbers = env.toc_secnumbers + env.toc_secnumbers = {} + + def _walk_toc( + node: Element, secnums: dict, depth: int, titlenode: nodes.title | None = None, + ) -> None: + # titlenode is the title of the document, it will get assigned a + # secnumber too, so that it shows up in next/prev/parent rellinks + for subnode in node.children: + if isinstance(subnode, nodes.bullet_list): + numstack.append(0) + _walk_toc(subnode, secnums, depth - 1, titlenode) + numstack.pop() + titlenode = None + elif isinstance(subnode, nodes.list_item): # NoQA: SIM114 + _walk_toc(subnode, secnums, depth, titlenode) + titlenode = None + elif isinstance(subnode, addnodes.only): + # at this stage we don't know yet which sections are going + # to be included; just include all of them, even if it leads + # to gaps in the numbering + _walk_toc(subnode, secnums, depth, titlenode) + titlenode = None + elif isinstance(subnode, addnodes.compact_paragraph): + if 'skip_section_number' in subnode: + continue + numstack[-1] += 1 + reference = cast(nodes.reference, subnode[0]) + if depth > 0: + number = list(numstack) + secnums[reference['anchorname']] = tuple(numstack) + else: + number = None + secnums[reference['anchorname']] = None + reference['secnumber'] = number + if titlenode: + titlenode['secnumber'] = number + titlenode = None + elif isinstance(subnode, addnodes.toctree): + _walk_toctree(subnode, depth) + + def _walk_toctree(toctreenode: addnodes.toctree, depth: int) -> None: + if depth == 0: + return + for (_title, ref) in toctreenode['entries']: + if url_re.match(ref) or ref == 'self': + # don't mess with those + continue + if ref in assigned: + logger.warning(__('%s is already assigned section numbers ' + '(nested numbered toctree?)'), ref, + location=toctreenode, type='toc', subtype='secnum') + elif ref in env.tocs: + secnums: dict[str, tuple[int, ...]] = {} + env.toc_secnumbers[ref] = secnums + assigned.add(ref) + _walk_toc(env.tocs[ref], secnums, depth, env.titles.get(ref)) + if secnums != old_secnumbers.get(ref): + rewrite_needed.append(ref) + + for docname in env.numbered_toctrees: + assigned.add(docname) + doctree = env.get_doctree(docname) + for toctreenode in doctree.findall(addnodes.toctree): + depth = toctreenode.get('numbered', 0) + if depth: + # every numbered toctree gets new numbering + numstack = [0] + _walk_toctree(toctreenode, depth) + + return rewrite_needed + + def assign_figure_numbers(self, env: BuildEnvironment) -> list[str]: + """Assign a figure number to each figure under a numbered toctree.""" + generated_docnames = frozenset(env.domains['std']._virtual_doc_names) + + rewrite_needed = [] + + assigned: set[str] = set() + old_fignumbers = env.toc_fignumbers + env.toc_fignumbers = {} + fignum_counter: dict[str, dict[tuple[int, ...], int]] = {} + + def get_figtype(node: Node) -> str | None: + for domain in env.domains.values(): + figtype = domain.get_enumerable_node_type(node) + if (domain.name == 'std' + and not domain.get_numfig_title(node)): # type: ignore[attr-defined] # NoQA: E501 + # Skip if uncaptioned node + continue + + if figtype: + return figtype + + return None + + def get_section_number(docname: str, section: nodes.section) -> tuple[int, ...]: + anchorname = '#' + section['ids'][0] + secnumbers = env.toc_secnumbers.get(docname, {}) + if anchorname in secnumbers: + secnum = secnumbers.get(anchorname) + else: + secnum = secnumbers.get('') + + return secnum or () + + def get_next_fignumber(figtype: str, secnum: tuple[int, ...]) -> tuple[int, ...]: + counter = fignum_counter.setdefault(figtype, {}) + + secnum = secnum[:env.config.numfig_secnum_depth] + counter[secnum] = counter.get(secnum, 0) + 1 + return secnum + (counter[secnum],) + + def register_fignumber(docname: str, secnum: tuple[int, ...], + figtype: str, fignode: Element) -> None: + env.toc_fignumbers.setdefault(docname, {}) + fignumbers = env.toc_fignumbers[docname].setdefault(figtype, {}) + figure_id = fignode['ids'][0] + + fignumbers[figure_id] = get_next_fignumber(figtype, secnum) + + def _walk_doctree(docname: str, doctree: Element, secnum: tuple[int, ...]) -> None: + nonlocal generated_docnames + for subnode in doctree.children: + if isinstance(subnode, nodes.section): + next_secnum = get_section_number(docname, subnode) + if next_secnum: + _walk_doctree(docname, subnode, next_secnum) + else: + _walk_doctree(docname, subnode, secnum) + elif isinstance(subnode, addnodes.toctree): + for _title, subdocname in subnode['entries']: + if url_re.match(subdocname) or subdocname == 'self': + # don't mess with those + continue + if subdocname in generated_docnames: + # or these + continue + + _walk_doc(subdocname, secnum) + elif isinstance(subnode, nodes.Element): + figtype = get_figtype(subnode) + if figtype and subnode['ids']: + register_fignumber(docname, secnum, figtype, subnode) + + _walk_doctree(docname, subnode, secnum) + + def _walk_doc(docname: str, secnum: tuple[int, ...]) -> None: + if docname not in assigned: + assigned.add(docname) + doctree = env.get_doctree(docname) + _walk_doctree(docname, doctree, secnum) + + if env.config.numfig: + _walk_doc(env.config.root_doc, ()) + for docname, fignums in env.toc_fignumbers.items(): + if fignums != old_fignumbers.get(docname): + rewrite_needed.append(docname) + + return rewrite_needed + + +def _make_anchor_name(ids: list[str], num_entries: list[int]) -> str: + if not num_entries[0]: + # for the very first toc entry, don't add an anchor + # as it is the file's title anyway + anchorname = '' + else: + anchorname = '#' + ids[0] + num_entries[0] += 1 + return anchorname + + +def setup(app: Sphinx) -> dict[str, Any]: + app.add_env_collector(TocTreeCollector) + + return { + 'version': 'builtin', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } |