summaryrefslogtreecommitdiffstats
path: root/sphinx/environment/collectors
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/environment/collectors')
-rw-r--r--sphinx/environment/collectors/__init__.py72
-rw-r--r--sphinx/environment/collectors/asset.py147
-rw-r--r--sphinx/environment/collectors/dependencies.py57
-rw-r--r--sphinx/environment/collectors/metadata.py70
-rw-r--r--sphinx/environment/collectors/title.py61
-rw-r--r--sphinx/environment/collectors/toctree.py355
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,
+ }