summaryrefslogtreecommitdiffstats
path: root/sphinx/environment
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/environment')
-rw-r--r--sphinx/environment/__init__.py779
-rw-r--r--sphinx/environment/adapters/__init__.py1
-rw-r--r--sphinx/environment/adapters/asset.py15
-rw-r--r--sphinx/environment/adapters/indexentries.py187
-rw-r--r--sphinx/environment/adapters/toctree.py520
-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
11 files changed, 2264 insertions, 0 deletions
diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py
new file mode 100644
index 0000000..9b9e9dd
--- /dev/null
+++ b/sphinx/environment/__init__.py
@@ -0,0 +1,779 @@
+"""Global creation environment."""
+
+from __future__ import annotations
+
+import functools
+import os
+import pickle
+import time
+from collections import defaultdict
+from copy import copy
+from os import path
+from typing import TYPE_CHECKING, Any, Callable
+
+from sphinx import addnodes
+from sphinx.environment.adapters import toctree as toctree_adapters
+from sphinx.errors import BuildEnvironmentError, DocumentError, ExtensionError, SphinxError
+from sphinx.locale import __
+from sphinx.transforms import SphinxTransformer
+from sphinx.util import DownloadFiles, FilenameUniqDict, logging
+from sphinx.util.docutils import LoggingReporter
+from sphinx.util.i18n import CatalogRepository, docname_to_domain
+from sphinx.util.nodes import is_translatable
+from sphinx.util.osutil import canon_path, os_path
+
+if TYPE_CHECKING:
+ from collections.abc import Generator, Iterator
+ from pathlib import Path
+
+ from docutils import nodes
+ from docutils.nodes import Node
+
+ from sphinx.application import Sphinx
+ from sphinx.builders import Builder
+ from sphinx.config import Config
+ from sphinx.domains import Domain
+ from sphinx.events import EventManager
+ from sphinx.project import Project
+
+logger = logging.getLogger(__name__)
+
+default_settings: dict[str, Any] = {
+ 'auto_id_prefix': 'id',
+ 'image_loading': 'link',
+ 'embed_stylesheet': False,
+ 'cloak_email_addresses': True,
+ 'pep_base_url': 'https://peps.python.org/',
+ 'pep_references': None,
+ 'rfc_base_url': 'https://datatracker.ietf.org/doc/html/',
+ 'rfc_references': None,
+ 'input_encoding': 'utf-8-sig',
+ 'doctitle_xform': False,
+ 'sectsubtitle_xform': False,
+ 'section_self_link': False,
+ 'halt_level': 5,
+ 'file_insertion_enabled': True,
+ 'smartquotes_locales': [],
+}
+
+# This is increased every time an environment attribute is added
+# or changed to properly invalidate pickle files.
+ENV_VERSION = 60
+
+# config status
+CONFIG_UNSET = -1
+CONFIG_OK = 1
+CONFIG_NEW = 2
+CONFIG_CHANGED = 3
+CONFIG_EXTENSIONS_CHANGED = 4
+
+CONFIG_CHANGED_REASON = {
+ CONFIG_NEW: __('new config'),
+ CONFIG_CHANGED: __('config changed'),
+ CONFIG_EXTENSIONS_CHANGED: __('extensions changed'),
+}
+
+
+versioning_conditions: dict[str, bool | Callable] = {
+ 'none': False,
+ 'text': is_translatable,
+}
+
+if TYPE_CHECKING:
+ from collections.abc import MutableMapping
+ from typing import Literal
+
+ from typing_extensions import overload
+
+ from sphinx.domains.c import CDomain
+ from sphinx.domains.changeset import ChangeSetDomain
+ from sphinx.domains.citation import CitationDomain
+ from sphinx.domains.cpp import CPPDomain
+ from sphinx.domains.index import IndexDomain
+ from sphinx.domains.javascript import JavaScriptDomain
+ from sphinx.domains.math import MathDomain
+ from sphinx.domains.python import PythonDomain
+ from sphinx.domains.rst import ReSTDomain
+ from sphinx.domains.std import StandardDomain
+ from sphinx.ext.duration import DurationDomain
+ from sphinx.ext.todo import TodoDomain
+
+ class _DomainsType(MutableMapping[str, Domain]):
+ @overload
+ def __getitem__(self, key: Literal["c"]) -> CDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["cpp"]) -> CPPDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["changeset"]) -> ChangeSetDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["citation"]) -> CitationDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["index"]) -> IndexDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["js"]) -> JavaScriptDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["math"]) -> MathDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["py"]) -> PythonDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["rst"]) -> ReSTDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["std"]) -> StandardDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["duration"]) -> DurationDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: Literal["todo"]) -> TodoDomain: ... # NoQA: E704
+ @overload
+ def __getitem__(self, key: str) -> Domain: ... # NoQA: E704
+ def __getitem__(self, key): raise NotImplementedError # NoQA: E704
+ def __setitem__(self, key, value): raise NotImplementedError # NoQA: E704
+ def __delitem__(self, key): raise NotImplementedError # NoQA: E704
+ def __iter__(self): raise NotImplementedError # NoQA: E704
+ def __len__(self): raise NotImplementedError # NoQA: E704
+
+else:
+ _DomainsType = dict
+
+
+class BuildEnvironment:
+ """
+ The environment in which the ReST files are translated.
+ Stores an inventory of cross-file targets and provides doctree
+ transformations to resolve links to them.
+ """
+
+ domains: _DomainsType
+
+ # --------- ENVIRONMENT INITIALIZATION -------------------------------------
+
+ def __init__(self, app: Sphinx):
+ self.app: Sphinx = app
+ self.doctreedir: Path = app.doctreedir
+ self.srcdir: Path = app.srcdir
+ self.config: Config = None # type: ignore[assignment]
+ self.config_status: int = CONFIG_UNSET
+ self.config_status_extra: str = ''
+ self.events: EventManager = app.events
+ self.project: Project = app.project
+ self.version: dict[str, str] = app.registry.get_envversion(app)
+
+ # the method of doctree versioning; see set_versioning_method
+ self.versioning_condition: bool | Callable | None = None
+ self.versioning_compare: bool | None = None
+
+ # all the registered domains, set by the application
+ self.domains = _DomainsType()
+
+ # the docutils settings for building
+ self.settings: dict[str, Any] = default_settings.copy()
+ self.settings['env'] = self
+
+ # All "docnames" here are /-separated and relative and exclude
+ # the source suffix.
+
+ # docname -> time of reading (in integer microseconds)
+ # contains all read docnames
+ self.all_docs: dict[str, int] = {}
+ # docname -> set of dependent file
+ # names, relative to documentation root
+ self.dependencies: dict[str, set[str]] = defaultdict(set)
+ # docname -> set of included file
+ # docnames included from other documents
+ self.included: dict[str, set[str]] = defaultdict(set)
+ # docnames to re-read unconditionally on next build
+ self.reread_always: set[str] = set()
+
+ # docname -> pickled doctree
+ self._pickled_doctree_cache: dict[str, bytes] = {}
+
+ # docname -> doctree
+ self._write_doc_doctree_cache: dict[str, nodes.document] = {}
+
+ # File metadata
+ # docname -> dict of metadata items
+ self.metadata: dict[str, dict[str, Any]] = defaultdict(dict)
+
+ # TOC inventory
+ # docname -> title node
+ self.titles: dict[str, nodes.title] = {}
+ # docname -> title node; only different if
+ # set differently with title directive
+ self.longtitles: dict[str, nodes.title] = {}
+ # docname -> table of contents nodetree
+ self.tocs: dict[str, nodes.bullet_list] = {}
+ # docname -> number of real entries
+ self.toc_num_entries: dict[str, int] = {}
+
+ # used to determine when to show the TOC
+ # in a sidebar (don't show if it's only one item)
+ # docname -> dict of sectionid -> number
+ self.toc_secnumbers: dict[str, dict[str, tuple[int, ...]]] = {}
+ # docname -> dict of figtype -> dict of figureid -> number
+ self.toc_fignumbers: dict[str, dict[str, dict[str, tuple[int, ...]]]] = {}
+
+ # docname -> list of toctree includefiles
+ self.toctree_includes: dict[str, list[str]] = {}
+ # docname -> set of files (containing its TOCs) to rebuild too
+ self.files_to_rebuild: dict[str, set[str]] = {}
+ # docnames that have :glob: toctrees
+ self.glob_toctrees: set[str] = set()
+ # docnames that have :numbered: toctrees
+ self.numbered_toctrees: set[str] = set()
+
+ # domain-specific inventories, here to be pickled
+ # domainname -> domain-specific dict
+ self.domaindata: dict[str, dict] = {}
+
+ # these map absolute path -> (docnames, unique filename)
+ self.images: FilenameUniqDict = FilenameUniqDict()
+ # filename -> (set of docnames, destination)
+ self.dlfiles: DownloadFiles = DownloadFiles()
+
+ # the original URI for images
+ self.original_image_uri: dict[str, str] = {}
+
+ # temporary data storage while reading a document
+ self.temp_data: dict[str, Any] = {}
+ # context for cross-references (e.g. current module or class)
+ # this is similar to temp_data, but will for example be copied to
+ # attributes of "any" cross references
+ self.ref_context: dict[str, Any] = {}
+
+ # search index data
+
+ # docname -> title
+ self._search_index_titles: dict[str, str] = {}
+ # docname -> filename
+ self._search_index_filenames: dict[str, str] = {}
+ # stemmed words -> set(docname)
+ self._search_index_mapping: dict[str, set[str]] = {}
+ # stemmed words in titles -> set(docname)
+ self._search_index_title_mapping: dict[str, set[str]] = {}
+ # docname -> all titles in document
+ self._search_index_all_titles: dict[str, list[tuple[str, str]]] = {}
+ # docname -> list(index entry)
+ self._search_index_index_entries: dict[str, list[tuple[str, str, str]]] = {}
+ # objtype -> index
+ self._search_index_objtypes: dict[tuple[str, str], int] = {}
+ # objtype index -> (domain, type, objname (localized))
+ self._search_index_objnames: dict[int, tuple[str, str, str]] = {}
+
+ # set up environment
+ self.setup(app)
+
+ def __getstate__(self) -> dict:
+ """Obtains serializable data for pickling."""
+ __dict__ = self.__dict__.copy()
+ __dict__.update(app=None, domains={}, events=None) # clear unpickable attributes
+ return __dict__
+
+ def __setstate__(self, state: dict) -> None:
+ self.__dict__.update(state)
+
+ def setup(self, app: Sphinx) -> None:
+ """Set up BuildEnvironment object."""
+ if self.version and self.version != app.registry.get_envversion(app):
+ raise BuildEnvironmentError(__('build environment version not current'))
+ if self.srcdir and self.srcdir != app.srcdir:
+ raise BuildEnvironmentError(__('source directory has changed'))
+
+ if self.project:
+ app.project.restore(self.project)
+
+ self.app = app
+ self.doctreedir = app.doctreedir
+ self.events = app.events
+ self.srcdir = app.srcdir
+ self.project = app.project
+ self.version = app.registry.get_envversion(app)
+
+ # initialize domains
+ self.domains = _DomainsType()
+ for domain in app.registry.create_domains(self):
+ self.domains[domain.name] = domain
+
+ # setup domains (must do after all initialization)
+ for domain in self.domains.values():
+ domain.setup()
+
+ # initialize config
+ self._update_config(app.config)
+
+ # initialie settings
+ self._update_settings(app.config)
+
+ def _update_config(self, config: Config) -> None:
+ """Update configurations by new one."""
+ self.config_status = CONFIG_OK
+ self.config_status_extra = ''
+ if self.config is None:
+ self.config_status = CONFIG_NEW
+ elif self.config.extensions != config.extensions:
+ self.config_status = CONFIG_EXTENSIONS_CHANGED
+ extensions = sorted(
+ set(self.config.extensions) ^ set(config.extensions))
+ if len(extensions) == 1:
+ extension = extensions[0]
+ else:
+ extension = '%d' % (len(extensions),)
+ self.config_status_extra = f' ({extension!r})'
+ else:
+ # check if a config value was changed that affects how
+ # doctrees are read
+ for item in config.filter('env'):
+ if self.config[item.name] != item.value:
+ self.config_status = CONFIG_CHANGED
+ self.config_status_extra = f' ({item.name!r})'
+ break
+
+ self.config = config
+
+ def _update_settings(self, config: Config) -> None:
+ """Update settings by new config."""
+ self.settings['input_encoding'] = config.source_encoding
+ self.settings['trim_footnote_reference_space'] = config.trim_footnote_reference_space
+ self.settings['language_code'] = config.language
+
+ # Allow to disable by 3rd party extension (workaround)
+ self.settings.setdefault('smart_quotes', True)
+
+ def set_versioning_method(self, method: str | Callable, compare: bool) -> None:
+ """This sets the doctree versioning method for this environment.
+
+ Versioning methods are a builder property; only builders with the same
+ versioning method can share the same doctree directory. Therefore, we
+ raise an exception if the user tries to use an environment with an
+ incompatible versioning method.
+ """
+ condition: bool | Callable
+ if callable(method):
+ condition = method
+ else:
+ if method not in versioning_conditions:
+ raise ValueError('invalid versioning method: %r' % method)
+ condition = versioning_conditions[method]
+
+ if self.versioning_condition not in (None, condition):
+ raise SphinxError(__('This environment is incompatible with the '
+ 'selected builder, please choose another '
+ 'doctree directory.'))
+ self.versioning_condition = condition
+ self.versioning_compare = compare
+
+ def clear_doc(self, docname: str) -> None:
+ """Remove all traces of a source file in the inventory."""
+ if docname in self.all_docs:
+ self.all_docs.pop(docname, None)
+ self.included.pop(docname, None)
+ self.reread_always.discard(docname)
+
+ for domain in self.domains.values():
+ domain.clear_doc(docname)
+
+ def merge_info_from(self, docnames: list[str], other: BuildEnvironment,
+ app: Sphinx) -> None:
+ """Merge global information gathered about *docnames* while reading them
+ from the *other* environment.
+
+ This possibly comes from a parallel build process.
+ """
+ docnames = set(docnames) # type: ignore[assignment]
+ for docname in docnames:
+ self.all_docs[docname] = other.all_docs[docname]
+ self.included[docname] = other.included[docname]
+ if docname in other.reread_always:
+ self.reread_always.add(docname)
+
+ for domainname, domain in self.domains.items():
+ domain.merge_domaindata(docnames, other.domaindata[domainname])
+ self.events.emit('env-merge-info', self, docnames, other)
+
+ def path2doc(self, filename: str | os.PathLike[str]) -> str | None:
+ """Return the docname for the filename if the file is document.
+
+ *filename* should be absolute or relative to the source directory.
+ """
+ return self.project.path2doc(filename)
+
+ def doc2path(self, docname: str, base: bool = True) -> str:
+ """Return the filename for the document name.
+
+ If *base* is True, return absolute path under self.srcdir.
+ If *base* is False, return relative path to self.srcdir.
+ """
+ return self.project.doc2path(docname, base)
+
+ def relfn2path(self, filename: str, docname: str | None = None) -> tuple[str, str]:
+ """Return paths to a file referenced from a document, relative to
+ documentation root and absolute.
+
+ In the input "filename", absolute filenames are taken as relative to the
+ source dir, while relative filenames are relative to the dir of the
+ containing document.
+ """
+ filename = os_path(filename)
+ if filename.startswith(('/', os.sep)):
+ rel_fn = filename[1:]
+ else:
+ docdir = path.dirname(self.doc2path(docname or self.docname,
+ base=False))
+ rel_fn = path.join(docdir, filename)
+
+ return (canon_path(path.normpath(rel_fn)),
+ path.normpath(path.join(self.srcdir, rel_fn)))
+
+ @property
+ def found_docs(self) -> set[str]:
+ """contains all existing docnames."""
+ return self.project.docnames
+
+ def find_files(self, config: Config, builder: Builder) -> None:
+ """Find all source files in the source dir and put them in
+ self.found_docs.
+ """
+ try:
+ exclude_paths = (self.config.exclude_patterns +
+ self.config.templates_path +
+ builder.get_asset_paths())
+ self.project.discover(exclude_paths, self.config.include_patterns)
+
+ # Current implementation is applying translated messages in the reading
+ # phase.Therefore, in order to apply the updated message catalog, it is
+ # necessary to re-process from the reading phase. Here, if dependency
+ # is set for the doc source and the mo file, it is processed again from
+ # the reading phase when mo is updated. In the future, we would like to
+ # move i18n process into the writing phase, and remove these lines.
+ if builder.use_message_catalog:
+ # add catalog mo file dependency
+ repo = CatalogRepository(self.srcdir, self.config.locale_dirs,
+ self.config.language, self.config.source_encoding)
+ mo_paths = {c.domain: c.mo_path for c in repo.catalogs}
+ for docname in self.found_docs:
+ domain = docname_to_domain(docname, self.config.gettext_compact)
+ if domain in mo_paths:
+ self.dependencies[docname].add(mo_paths[domain])
+ except OSError as exc:
+ raise DocumentError(__('Failed to scan documents in %s: %r') %
+ (self.srcdir, exc)) from exc
+
+ def get_outdated_files(self, config_changed: bool) -> tuple[set[str], set[str], set[str]]:
+ """Return (added, changed, removed) sets."""
+ # clear all files no longer present
+ removed = set(self.all_docs) - self.found_docs
+
+ added: set[str] = set()
+ changed: set[str] = set()
+
+ if config_changed:
+ # config values affect e.g. substitutions
+ added = self.found_docs
+ else:
+ for docname in self.found_docs:
+ if docname not in self.all_docs:
+ logger.debug('[build target] added %r', docname)
+ added.add(docname)
+ continue
+ # if the doctree file is not there, rebuild
+ filename = path.join(self.doctreedir, docname + '.doctree')
+ if not path.isfile(filename):
+ logger.debug('[build target] changed %r', docname)
+ changed.add(docname)
+ continue
+ # check the "reread always" list
+ if docname in self.reread_always:
+ logger.debug('[build target] changed %r', docname)
+ changed.add(docname)
+ continue
+ # check the mtime of the document
+ mtime = self.all_docs[docname]
+ newmtime = _last_modified_time(self.doc2path(docname))
+ if newmtime > mtime:
+ logger.debug('[build target] outdated %r: %s -> %s',
+ docname,
+ _format_modified_time(mtime), _format_modified_time(newmtime))
+ changed.add(docname)
+ continue
+ # finally, check the mtime of dependencies
+ for dep in self.dependencies[docname]:
+ try:
+ # this will do the right thing when dep is absolute too
+ deppath = path.join(self.srcdir, dep)
+ if not path.isfile(deppath):
+ logger.debug(
+ '[build target] changed %r missing dependency %r',
+ docname, deppath,
+ )
+ changed.add(docname)
+ break
+ depmtime = _last_modified_time(deppath)
+ if depmtime > mtime:
+ logger.debug(
+ '[build target] outdated %r from dependency %r: %s -> %s',
+ docname, deppath,
+ _format_modified_time(mtime), _format_modified_time(depmtime),
+ )
+ changed.add(docname)
+ break
+ except OSError:
+ # give it another chance
+ changed.add(docname)
+ break
+
+ return added, changed, removed
+
+ def check_dependents(self, app: Sphinx, already: set[str]) -> Generator[str, None, None]:
+ to_rewrite: list[str] = []
+ for docnames in self.events.emit('env-get-updated', self):
+ to_rewrite.extend(docnames)
+ for docname in set(to_rewrite):
+ if docname not in already:
+ yield docname
+
+ # --------- SINGLE FILE READING --------------------------------------------
+
+ def prepare_settings(self, docname: str) -> None:
+ """Prepare to set up environment for reading."""
+ self.temp_data['docname'] = docname
+ # defaults to the global default, but can be re-set in a document
+ self.temp_data['default_role'] = self.config.default_role
+ self.temp_data['default_domain'] = \
+ self.domains.get(self.config.primary_domain)
+
+ # utilities to use while reading a document
+
+ @property
+ def docname(self) -> str:
+ """Returns the docname of the document currently being parsed."""
+ return self.temp_data['docname']
+
+ def new_serialno(self, category: str = '') -> int:
+ """Return a serial number, e.g. for index entry targets.
+
+ The number is guaranteed to be unique in the current document.
+ """
+ key = category + 'serialno'
+ cur = self.temp_data.get(key, 0)
+ self.temp_data[key] = cur + 1
+ return cur
+
+ def note_dependency(self, filename: str) -> None:
+ """Add *filename* as a dependency of the current document.
+
+ This means that the document will be rebuilt if this file changes.
+
+ *filename* should be absolute or relative to the source directory.
+ """
+ self.dependencies[self.docname].add(filename)
+
+ def note_included(self, filename: str) -> None:
+ """Add *filename* as a included from other document.
+
+ This means the document is not orphaned.
+
+ *filename* should be absolute or relative to the source directory.
+ """
+ doc = self.path2doc(filename)
+ if doc:
+ self.included[self.docname].add(doc)
+
+ def note_reread(self) -> None:
+ """Add the current document to the list of documents that will
+ automatically be re-read at the next build.
+ """
+ self.reread_always.add(self.docname)
+
+ def get_domain(self, domainname: str) -> Domain:
+ """Return the domain instance with the specified name.
+
+ Raises an ExtensionError if the domain is not registered.
+ """
+ try:
+ return self.domains[domainname]
+ except KeyError as exc:
+ raise ExtensionError(__('Domain %r is not registered') % domainname) from exc
+
+ # --------- RESOLVING REFERENCES AND TOCTREES ------------------------------
+
+ def get_doctree(self, docname: str) -> nodes.document:
+ """Read the doctree for a file from the pickle and return it."""
+ try:
+ serialised = self._pickled_doctree_cache[docname]
+ except KeyError:
+ filename = path.join(self.doctreedir, docname + '.doctree')
+ with open(filename, 'rb') as f:
+ serialised = self._pickled_doctree_cache[docname] = f.read()
+
+ doctree = pickle.loads(serialised)
+ doctree.settings.env = self
+ doctree.reporter = LoggingReporter(self.doc2path(docname))
+ return doctree
+
+ @functools.cached_property
+ def master_doctree(self) -> nodes.document:
+ return self.get_doctree(self.config.root_doc)
+
+ def get_and_resolve_doctree(
+ self,
+ docname: str,
+ builder: Builder,
+ doctree: nodes.document | None = None,
+ prune_toctrees: bool = True,
+ includehidden: bool = False,
+ ) -> nodes.document:
+ """Read the doctree from the pickle, resolve cross-references and
+ toctrees and return it.
+ """
+ if doctree is None:
+ try:
+ doctree = self._write_doc_doctree_cache.pop(docname)
+ doctree.settings.env = self
+ doctree.reporter = LoggingReporter(self.doc2path(docname))
+ except KeyError:
+ doctree = self.get_doctree(docname)
+
+ # resolve all pending cross-references
+ self.apply_post_transforms(doctree, docname)
+
+ # now, resolve all toctree nodes
+ for toctreenode in doctree.findall(addnodes.toctree):
+ result = toctree_adapters._resolve_toctree(
+ self, docname, builder, toctreenode,
+ prune=prune_toctrees,
+ includehidden=includehidden,
+ )
+ if result is None:
+ toctreenode.parent.replace(toctreenode, [])
+ else:
+ toctreenode.replace_self(result)
+
+ return doctree
+
+ def resolve_toctree(self, docname: str, builder: Builder, toctree: addnodes.toctree,
+ prune: bool = True, maxdepth: int = 0, titles_only: bool = False,
+ collapse: bool = False, includehidden: bool = False) -> Node | None:
+ """Resolve a *toctree* node into individual bullet lists with titles
+ as items, returning None (if no containing titles are found) or
+ a new node.
+
+ If *prune* is True, the tree is pruned to *maxdepth*, or if that is 0,
+ to the value of the *maxdepth* option on the *toctree* node.
+ If *titles_only* is True, only toplevel document titles will be in the
+ resulting tree.
+ If *collapse* is True, all branches not containing docname will
+ be collapsed.
+ """
+ return toctree_adapters._resolve_toctree(
+ self, docname, builder, toctree,
+ prune=prune,
+ maxdepth=maxdepth,
+ titles_only=titles_only,
+ collapse=collapse,
+ includehidden=includehidden,
+ )
+
+ def resolve_references(self, doctree: nodes.document, fromdocname: str,
+ builder: Builder) -> None:
+ self.apply_post_transforms(doctree, fromdocname)
+
+ def apply_post_transforms(self, doctree: nodes.document, docname: str) -> None:
+ """Apply all post-transforms."""
+ try:
+ # set env.docname during applying post-transforms
+ backup = copy(self.temp_data)
+ self.temp_data['docname'] = docname
+
+ transformer = SphinxTransformer(doctree)
+ transformer.set_environment(self)
+ transformer.add_transforms(self.app.registry.get_post_transforms())
+ transformer.apply_transforms()
+ finally:
+ self.temp_data = backup
+
+ # allow custom references to be resolved
+ self.events.emit('doctree-resolved', doctree, docname)
+
+ def collect_relations(self) -> dict[str, list[str | None]]:
+ traversed: set[str] = set()
+
+ relations = {}
+ docnames = _traverse_toctree(
+ traversed, None, self.config.root_doc, self.toctree_includes,
+ )
+ prev_doc = None
+ parent, docname = next(docnames)
+ for next_parent, next_doc in docnames:
+ relations[docname] = [parent, prev_doc, next_doc]
+ prev_doc = docname
+ docname = next_doc
+ parent = next_parent
+
+ relations[docname] = [parent, prev_doc, None]
+
+ return relations
+
+ def check_consistency(self) -> None:
+ """Do consistency checks."""
+ included = set().union(*self.included.values())
+ for docname in sorted(self.all_docs):
+ if docname not in self.files_to_rebuild:
+ if docname == self.config.root_doc:
+ # the master file is not included anywhere ;)
+ continue
+ if docname in included:
+ # the document is included from other documents
+ continue
+ if 'orphan' in self.metadata[docname]:
+ continue
+ logger.warning(__("document isn't included in any toctree"),
+ location=docname)
+
+ # call check-consistency for all extensions
+ for domain in self.domains.values():
+ domain.check_consistency()
+ self.events.emit('env-check-consistency', self)
+
+
+def _last_modified_time(filename: str | os.PathLike[str]) -> int:
+ """Return the last modified time of ``filename``.
+
+ The time is returned as integer microseconds.
+ The lowest common denominator of modern file-systems seems to be
+ microsecond-level precision.
+
+ We prefer to err on the side of re-rendering a file,
+ so we round up to the nearest microsecond.
+ """
+
+ # upside-down floor division to get the ceiling
+ return -(os.stat(filename).st_mtime_ns // -1_000)
+
+
+def _format_modified_time(timestamp: int) -> str:
+ """Return an RFC 3339 formatted string representing the given timestamp."""
+ seconds, fraction = divmod(timestamp, 10**6)
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(seconds)) + f'.{fraction//1_000}'
+
+
+def _traverse_toctree(
+ traversed: set[str],
+ parent: str | None,
+ docname: str,
+ toctree_includes: dict[str, list[str]],
+) -> Iterator[tuple[str | None, str]]:
+ if parent == docname:
+ logger.warning(__('self referenced toctree found. Ignored.'),
+ location=docname, type='toc',
+ subtype='circular')
+ return
+
+ # traverse toctree by pre-order
+ yield parent, docname
+ traversed.add(docname)
+
+ for child in toctree_includes.get(docname, ()):
+ for sub_parent, sub_docname in _traverse_toctree(
+ traversed, docname, child, toctree_includes,
+ ):
+ if sub_docname not in traversed:
+ yield sub_parent, sub_docname
+ traversed.add(sub_docname)
diff --git a/sphinx/environment/adapters/__init__.py b/sphinx/environment/adapters/__init__.py
new file mode 100644
index 0000000..1566aec
--- /dev/null
+++ b/sphinx/environment/adapters/__init__.py
@@ -0,0 +1 @@
+"""Sphinx environment adapters"""
diff --git a/sphinx/environment/adapters/asset.py b/sphinx/environment/adapters/asset.py
new file mode 100644
index 0000000..57fdc91
--- /dev/null
+++ b/sphinx/environment/adapters/asset.py
@@ -0,0 +1,15 @@
+"""Assets adapter for sphinx.environment."""
+
+from sphinx.environment import BuildEnvironment
+
+
+class ImageAdapter:
+ def __init__(self, env: BuildEnvironment) -> None:
+ self.env = env
+
+ def get_original_image_uri(self, name: str) -> str:
+ """Get the original image URI."""
+ while name in self.env.original_image_uri:
+ name = self.env.original_image_uri[name]
+
+ return name
diff --git a/sphinx/environment/adapters/indexentries.py b/sphinx/environment/adapters/indexentries.py
new file mode 100644
index 0000000..6fdbea6
--- /dev/null
+++ b/sphinx/environment/adapters/indexentries.py
@@ -0,0 +1,187 @@
+"""Index entries adapters for sphinx.environment."""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from itertools import groupby
+from typing import TYPE_CHECKING, Any, Literal
+
+from sphinx.errors import NoUri
+from sphinx.locale import _, __
+from sphinx.util import logging
+from sphinx.util.index_entries import _split_into
+
+if TYPE_CHECKING:
+ from sphinx.builders import Builder
+ from sphinx.environment import BuildEnvironment
+
+logger = logging.getLogger(__name__)
+
+
+class IndexEntries:
+ def __init__(self, env: BuildEnvironment) -> None:
+ self.env = env
+ self.builder: Builder
+
+ def create_index(self, builder: Builder, group_entries: bool = True,
+ _fixre: re.Pattern = re.compile(r'(.*) ([(][^()]*[)])'),
+ ) -> list[tuple[str, list[tuple[str, Any]]]]:
+ """Create the real index from the collected index entries."""
+ new: dict[str, list] = {}
+
+ rel_uri: str | Literal[False]
+ index_domain = self.env.domains['index']
+ for docname, entries in index_domain.entries.items():
+ try:
+ rel_uri = builder.get_relative_uri('genindex', docname)
+ except NoUri:
+ rel_uri = False
+
+ # new entry types must be listed in directives/other.py!
+ for entry_type, value, target_id, main, category_key in entries:
+ uri = rel_uri is not False and f'{rel_uri}#{target_id}'
+ try:
+ if entry_type == 'single':
+ try:
+ entry, sub_entry = _split_into(2, 'single', value)
+ except ValueError:
+ entry, = _split_into(1, 'single', value)
+ sub_entry = ''
+ _add_entry(entry, sub_entry, main,
+ dic=new, link=uri, key=category_key)
+ elif entry_type == 'pair':
+ first, second = _split_into(2, 'pair', value)
+ _add_entry(first, second, main,
+ dic=new, link=uri, key=category_key)
+ _add_entry(second, first, main,
+ dic=new, link=uri, key=category_key)
+ elif entry_type == 'triple':
+ first, second, third = _split_into(3, 'triple', value)
+ _add_entry(first, second + ' ' + third, main,
+ dic=new, link=uri, key=category_key)
+ _add_entry(second, third + ', ' + first, main,
+ dic=new, link=uri, key=category_key)
+ _add_entry(third, first + ' ' + second, main,
+ dic=new, link=uri, key=category_key)
+ elif entry_type == 'see':
+ first, second = _split_into(2, 'see', value)
+ _add_entry(first, _('see %s') % second, None,
+ dic=new, link=False, key=category_key)
+ elif entry_type == 'seealso':
+ first, second = _split_into(2, 'see', value)
+ _add_entry(first, _('see also %s') % second, None,
+ dic=new, link=False, key=category_key)
+ else:
+ logger.warning(__('unknown index entry type %r'), entry_type,
+ location=docname)
+ except ValueError as err:
+ logger.warning(str(err), location=docname)
+
+ for (targets, sub_items, _category_key) in new.values():
+ targets.sort(key=_key_func_0)
+ for (sub_targets, _0, _sub_category_key) in sub_items.values():
+ sub_targets.sort(key=_key_func_0)
+
+ new_list = sorted(new.items(), key=_key_func_1)
+
+ if group_entries:
+ # fixup entries: transform
+ # func() (in module foo)
+ # func() (in module bar)
+ # into
+ # func()
+ # (in module foo)
+ # (in module bar)
+ old_key = ''
+ old_sub_items: dict[str, list] = {}
+ i = 0
+ while i < len(new_list):
+ key, (targets, sub_items, category_key) = new_list[i]
+ # cannot move if it has sub_items; structure gets too complex
+ if not sub_items:
+ m = _fixre.match(key)
+ if m:
+ if old_key == m.group(1):
+ # prefixes match: add entry as subitem of the
+ # previous entry
+ old_sub_items.setdefault(
+ m.group(2), [[], {}, category_key])[0].extend(targets)
+ del new_list[i]
+ continue
+ old_key = m.group(1)
+ else:
+ old_key = key
+ old_sub_items = sub_items
+ i += 1
+
+ return [(key_, list(group))
+ for (key_, group) in groupby(new_list, _key_func_3)]
+
+
+def _add_entry(word: str, subword: str, main: str | None, *,
+ dic: dict[str, list], link: str | Literal[False], key: str | None) -> None:
+ entry = dic.setdefault(word, [[], {}, key])
+ if subword:
+ entry = entry[1].setdefault(subword, [[], {}, key])
+ if link:
+ entry[0].append((main, link))
+
+
+def _key_func_0(entry: tuple[str, str]) -> tuple[bool, str]:
+ """sort the index entries for same keyword."""
+ main, uri = entry
+ return not main, uri # show main entries at first
+
+
+def _key_func_1(entry: tuple[str, list]) -> tuple[tuple[int, str], str]:
+ """Sort the index entries"""
+ key, (_targets, _sub_items, category_key) = entry
+ if category_key:
+ # using the specified category key to sort
+ key = category_key
+ lc_key = unicodedata.normalize('NFD', key.lower())
+ if lc_key.startswith('\N{RIGHT-TO-LEFT MARK}'):
+ lc_key = lc_key[1:]
+
+ if not lc_key[0:1].isalpha() and not lc_key.startswith('_'):
+ # put symbols at the front of the index (0)
+ group = 0
+ else:
+ # put non-symbol characters at the following group (1)
+ group = 1
+ # ensure a deterministic order *within* letters by also sorting on
+ # the entry itself
+ return (group, lc_key), entry[0]
+
+
+def _key_func_2(entry: tuple[str, list]) -> str:
+ """sort the sub-index entries"""
+ key = unicodedata.normalize('NFD', entry[0].lower())
+ if key.startswith('\N{RIGHT-TO-LEFT MARK}'):
+ key = key[1:]
+ if key[0:1].isalpha() or key.startswith('_'):
+ key = chr(127) + key
+ return key
+
+
+def _key_func_3(entry: tuple[str, list]) -> str:
+ """Group the entries by letter"""
+ key, (targets, sub_items, category_key) = entry
+ # hack: mutating the sub_items dicts to a list in the key_func
+ entry[1][1] = sorted(((sub_key, sub_targets)
+ for (sub_key, (sub_targets, _0, _sub_category_key))
+ in sub_items.items()), key=_key_func_2)
+
+ if category_key is not None:
+ return category_key
+
+ # now calculate the key
+ if key.startswith('\N{RIGHT-TO-LEFT MARK}'):
+ key = key[1:]
+ letter = unicodedata.normalize('NFD', key[0])[0].upper()
+ if letter.isalpha() or letter == '_':
+ return letter
+
+ # get all other symbols under one heading
+ return _('Symbols')
diff --git a/sphinx/environment/adapters/toctree.py b/sphinx/environment/adapters/toctree.py
new file mode 100644
index 0000000..e50d10b
--- /dev/null
+++ b/sphinx/environment/adapters/toctree.py
@@ -0,0 +1,520 @@
+"""Toctree adapter for sphinx.environment."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, TypeVar
+
+from docutils import nodes
+from docutils.nodes import Element, Node
+
+from sphinx import addnodes
+from sphinx.locale import __
+from sphinx.util import logging, url_re
+from sphinx.util.matching import Matcher
+from sphinx.util.nodes import _only_node_keep_children, clean_astext
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Set
+
+ from sphinx.builders import Builder
+ from sphinx.environment import BuildEnvironment
+ from sphinx.util.tags import Tags
+
+
+logger = logging.getLogger(__name__)
+
+
+def note_toctree(env: BuildEnvironment, docname: str, toctreenode: addnodes.toctree) -> None:
+ """Note a TOC tree directive in a document and gather information about
+ file relations from it.
+ """
+ if toctreenode['glob']:
+ env.glob_toctrees.add(docname)
+ if toctreenode.get('numbered'):
+ env.numbered_toctrees.add(docname)
+ include_files = toctreenode['includefiles']
+ for include_file in include_files:
+ # note that if the included file is rebuilt, this one must be
+ # too (since the TOC of the included file could have changed)
+ env.files_to_rebuild.setdefault(include_file, set()).add(docname)
+ env.toctree_includes.setdefault(docname, []).extend(include_files)
+
+
+def document_toc(env: BuildEnvironment, docname: str, tags: Tags) -> Node:
+ """Get the (local) table of contents for a document.
+
+ Note that this is only the sections within the document.
+ For a ToC tree that shows the document's place in the
+ ToC structure, use `get_toctree_for`.
+ """
+
+ tocdepth = env.metadata[docname].get('tocdepth', 0)
+ try:
+ toc = _toctree_copy(env.tocs[docname], 2, tocdepth, False, tags)
+ except KeyError:
+ # the document does not exist any more:
+ # return a dummy node that renders to nothing
+ return nodes.paragraph()
+
+ for node in toc.findall(nodes.reference):
+ node['refuri'] = node['anchorname'] or '#'
+ return toc
+
+
+def global_toctree_for_doc(
+ env: BuildEnvironment,
+ docname: str,
+ builder: Builder,
+ collapse: bool = False,
+ includehidden: bool = True,
+ maxdepth: int = 0,
+ titles_only: bool = False,
+) -> Element | None:
+ """Get the global ToC tree at a given document.
+
+ This gives the global ToC, with all ancestors and their siblings.
+ """
+
+ toctrees: list[Element] = []
+ for toctree_node in env.master_doctree.findall(addnodes.toctree):
+ if toctree := _resolve_toctree(
+ env,
+ docname,
+ builder,
+ toctree_node,
+ prune=True,
+ maxdepth=int(maxdepth),
+ titles_only=titles_only,
+ collapse=collapse,
+ includehidden=includehidden,
+ ):
+ toctrees.append(toctree)
+ if not toctrees:
+ return None
+ result = toctrees[0]
+ for toctree in toctrees[1:]:
+ result.extend(toctree.children)
+ return result
+
+
+def _resolve_toctree(
+ env: BuildEnvironment, docname: str, builder: Builder, toctree: addnodes.toctree, *,
+ prune: bool = True, maxdepth: int = 0, titles_only: bool = False,
+ collapse: bool = False, includehidden: bool = False,
+) -> Element | None:
+ """Resolve a *toctree* node into individual bullet lists with titles
+ as items, returning None (if no containing titles are found) or
+ a new node.
+
+ If *prune* is True, the tree is pruned to *maxdepth*, or if that is 0,
+ to the value of the *maxdepth* option on the *toctree* node.
+ If *titles_only* is True, only toplevel document titles will be in the
+ resulting tree.
+ If *collapse* is True, all branches not containing docname will
+ be collapsed.
+ """
+
+ if toctree.get('hidden', False) and not includehidden:
+ return None
+
+ # For reading the following two helper function, it is useful to keep
+ # in mind the node structure of a toctree (using HTML-like node names
+ # for brevity):
+ #
+ # <ul>
+ # <li>
+ # <p><a></p>
+ # <p><a></p>
+ # ...
+ # <ul>
+ # ...
+ # </ul>
+ # </li>
+ # </ul>
+ #
+ # The transformation is made in two passes in order to avoid
+ # interactions between marking and pruning the tree (see bug #1046).
+
+ toctree_ancestors = _get_toctree_ancestors(env.toctree_includes, docname)
+ included = Matcher(env.config.include_patterns)
+ excluded = Matcher(env.config.exclude_patterns)
+
+ maxdepth = maxdepth or toctree.get('maxdepth', -1)
+ if not titles_only and toctree.get('titlesonly', False):
+ titles_only = True
+ if not includehidden and toctree.get('includehidden', False):
+ includehidden = True
+
+ tocentries = _entries_from_toctree(
+ env,
+ prune,
+ titles_only,
+ collapse,
+ includehidden,
+ builder.tags,
+ toctree_ancestors,
+ included,
+ excluded,
+ toctree,
+ [],
+ )
+ if not tocentries:
+ return None
+
+ newnode = addnodes.compact_paragraph('', '')
+ if caption := toctree.attributes.get('caption'):
+ caption_node = nodes.title(caption, '', *[nodes.Text(caption)])
+ caption_node.line = toctree.line
+ caption_node.source = toctree.source
+ caption_node.rawsource = toctree['rawcaption']
+ if hasattr(toctree, 'uid'):
+ # move uid to caption_node to translate it
+ caption_node.uid = toctree.uid # type: ignore[attr-defined]
+ del toctree.uid
+ newnode.append(caption_node)
+ newnode.extend(tocentries)
+ newnode['toctree'] = True
+
+ # prune the tree to maxdepth, also set toc depth and current classes
+ _toctree_add_classes(newnode, 1, docname)
+ newnode = _toctree_copy(newnode, 1, maxdepth if prune else 0, collapse, builder.tags)
+
+ if isinstance(newnode[-1], nodes.Element) and len(newnode[-1]) == 0: # No titles found
+ return None
+
+ # set the target paths in the toctrees (they are not known at TOC
+ # generation time)
+ for refnode in newnode.findall(nodes.reference):
+ if url_re.match(refnode['refuri']) is None:
+ rel_uri = builder.get_relative_uri(docname, refnode['refuri'])
+ refnode['refuri'] = rel_uri + refnode['anchorname']
+ return newnode
+
+
+def _entries_from_toctree(
+ env: BuildEnvironment,
+ prune: bool,
+ titles_only: bool,
+ collapse: bool,
+ includehidden: bool,
+ tags: Tags,
+ toctree_ancestors: Set[str],
+ included: Matcher,
+ excluded: Matcher,
+ toctreenode: addnodes.toctree,
+ parents: list[str],
+ subtree: bool = False,
+) -> list[Element]:
+ """Return TOC entries for a toctree node."""
+ entries: list[Element] = []
+ for (title, ref) in toctreenode['entries']:
+ try:
+ toc, refdoc = _toctree_entry(
+ title, ref, env, prune, collapse, tags, toctree_ancestors,
+ included, excluded, toctreenode, parents,
+ )
+ except LookupError:
+ continue
+
+ # children of toc are:
+ # - list_item + compact_paragraph + (reference and subtoc)
+ # - only + subtoc
+ # - toctree
+ children: Iterable[nodes.Element] = toc.children # type: ignore[assignment]
+
+ # if titles_only is given, only keep the main title and
+ # sub-toctrees
+ if titles_only:
+ # delete everything but the toplevel title(s)
+ # and toctrees
+ for top_level in children:
+ # nodes with length 1 don't have any children anyway
+ if len(top_level) > 1:
+ if subtrees := list(top_level.findall(addnodes.toctree)):
+ top_level[1][:] = subtrees # type: ignore[index]
+ else:
+ top_level.pop(1)
+ # resolve all sub-toctrees
+ for sub_toc_node in list(toc.findall(addnodes.toctree)):
+ if sub_toc_node.get('hidden', False) and not includehidden:
+ continue
+ for i, entry in enumerate(
+ _entries_from_toctree(
+ env,
+ prune,
+ titles_only,
+ collapse,
+ includehidden,
+ tags,
+ toctree_ancestors,
+ included,
+ excluded,
+ sub_toc_node,
+ [refdoc] + parents,
+ subtree=True,
+ ),
+ start=sub_toc_node.parent.index(sub_toc_node) + 1,
+ ):
+ sub_toc_node.parent.insert(i, entry)
+ sub_toc_node.parent.remove(sub_toc_node)
+
+ entries.extend(children)
+
+ if not subtree:
+ ret = nodes.bullet_list()
+ ret += entries
+ return [ret]
+
+ return entries
+
+
+def _toctree_entry(
+ title: str,
+ ref: str,
+ env: BuildEnvironment,
+ prune: bool,
+ collapse: bool,
+ tags: Tags,
+ toctree_ancestors: Set[str],
+ included: Matcher,
+ excluded: Matcher,
+ toctreenode: addnodes.toctree,
+ parents: list[str],
+) -> tuple[Element, str]:
+ from sphinx.domains.std import StandardDomain
+
+ try:
+ refdoc = ''
+ if url_re.match(ref):
+ toc = _toctree_url_entry(title, ref)
+ elif ref == 'self':
+ toc = _toctree_self_entry(title, toctreenode['parent'], env.titles)
+ elif ref in StandardDomain._virtual_doc_names:
+ toc = _toctree_generated_entry(title, ref)
+ else:
+ if ref in parents:
+ logger.warning(__('circular toctree references '
+ 'detected, ignoring: %s <- %s'),
+ ref, ' <- '.join(parents),
+ location=ref, type='toc', subtype='circular')
+ msg = 'circular reference'
+ raise LookupError(msg)
+
+ toc, refdoc = _toctree_standard_entry(
+ title,
+ ref,
+ env.metadata[ref].get('tocdepth', 0),
+ env.tocs[ref],
+ toctree_ancestors,
+ prune,
+ collapse,
+ tags,
+ )
+
+ if not toc.children:
+ # empty toc means: no titles will show up in the toctree
+ logger.warning(__('toctree contains reference to document %r that '
+ "doesn't have a title: no link will be generated"),
+ ref, location=toctreenode)
+ except KeyError:
+ # this is raised if the included file does not exist
+ ref_path = env.doc2path(ref, False)
+ if excluded(ref_path):
+ message = __('toctree contains reference to excluded document %r')
+ elif not included(ref_path):
+ message = __('toctree contains reference to non-included document %r')
+ else:
+ message = __('toctree contains reference to nonexisting document %r')
+
+ logger.warning(message, ref, location=toctreenode)
+ raise
+ return toc, refdoc
+
+
+def _toctree_url_entry(title: str, ref: str) -> nodes.bullet_list:
+ if title is None:
+ title = ref
+ reference = nodes.reference('', '', internal=False,
+ refuri=ref, anchorname='',
+ *[nodes.Text(title)])
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ toc = nodes.bullet_list('', item)
+ return toc
+
+
+def _toctree_self_entry(
+ title: str, ref: str, titles: dict[str, nodes.title],
+) -> nodes.bullet_list:
+ # 'self' refers to the document from which this
+ # toctree originates
+ if not title:
+ title = clean_astext(titles[ref])
+ reference = nodes.reference('', '', internal=True,
+ refuri=ref,
+ anchorname='',
+ *[nodes.Text(title)])
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ # don't show subitems
+ toc = nodes.bullet_list('', item)
+ return toc
+
+
+def _toctree_generated_entry(title: str, ref: str) -> nodes.bullet_list:
+ from sphinx.domains.std import StandardDomain
+
+ docname, sectionname = StandardDomain._virtual_doc_names[ref]
+ if not title:
+ title = sectionname
+ reference = nodes.reference('', title, internal=True,
+ refuri=docname, anchorname='')
+ para = addnodes.compact_paragraph('', '', reference)
+ item = nodes.list_item('', para)
+ # don't show subitems
+ toc = nodes.bullet_list('', item)
+ return toc
+
+
+def _toctree_standard_entry(
+ title: str,
+ ref: str,
+ maxdepth: int,
+ toc: nodes.bullet_list,
+ toctree_ancestors: Set[str],
+ prune: bool,
+ collapse: bool,
+ tags: Tags,
+) -> tuple[nodes.bullet_list, str]:
+ refdoc = ref
+ if ref in toctree_ancestors and (not prune or maxdepth <= 0):
+ toc = toc.deepcopy()
+ else:
+ toc = _toctree_copy(toc, 2, maxdepth, collapse, tags)
+
+ if title and toc.children and len(toc.children) == 1:
+ child = toc.children[0]
+ for refnode in child.findall(nodes.reference):
+ if refnode['refuri'] == ref and not refnode['anchorname']:
+ refnode.children[:] = [nodes.Text(title)]
+ return toc, refdoc
+
+
+def _toctree_add_classes(node: Element, depth: int, docname: str) -> None:
+ """Add 'toctree-l%d' and 'current' classes to the toctree."""
+ for subnode in node.children:
+ if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)):
+ # for <p> and <li>, indicate the depth level and recurse
+ subnode['classes'].append(f'toctree-l{depth - 1}')
+ _toctree_add_classes(subnode, depth, docname)
+ elif isinstance(subnode, nodes.bullet_list):
+ # for <ul>, just recurse
+ _toctree_add_classes(subnode, depth + 1, docname)
+ elif isinstance(subnode, nodes.reference):
+ # for <a>, identify which entries point to the current
+ # document and therefore may not be collapsed
+ if subnode['refuri'] == docname:
+ if not subnode['anchorname']:
+ # give the whole branch a 'current' class
+ # (useful for styling it differently)
+ branchnode: Element = subnode
+ while branchnode:
+ branchnode['classes'].append('current')
+ branchnode = branchnode.parent
+ # mark the list_item as "on current page"
+ if subnode.parent.parent.get('iscurrent'):
+ # but only if it's not already done
+ return
+ while subnode:
+ subnode['iscurrent'] = True
+ subnode = subnode.parent
+
+
+ET = TypeVar('ET', bound=Element)
+
+
+def _toctree_copy(node: ET, depth: int, maxdepth: int, collapse: bool, tags: Tags) -> ET:
+ """Utility: Cut and deep-copy a TOC at a specified depth."""
+ keep_bullet_list_sub_nodes = (depth <= 1
+ or ((depth <= maxdepth or maxdepth <= 0)
+ and (not collapse or 'iscurrent' in node)))
+
+ copy = node.copy()
+ for subnode in node.children:
+ if isinstance(subnode, (addnodes.compact_paragraph, nodes.list_item)):
+ # for <p> and <li>, just recurse
+ copy.append(_toctree_copy(subnode, depth, maxdepth, collapse, tags))
+ elif isinstance(subnode, nodes.bullet_list):
+ # for <ul>, copy if the entry is top-level
+ # or, copy if the depth is within bounds and;
+ # collapsing is disabled or the sub-entry's parent is 'current'.
+ # The boolean is constant so is calculated outwith the loop.
+ if keep_bullet_list_sub_nodes:
+ copy.append(_toctree_copy(subnode, depth + 1, maxdepth, collapse, tags))
+ elif isinstance(subnode, addnodes.toctree):
+ # copy sub toctree nodes for later processing
+ copy.append(subnode.copy())
+ elif isinstance(subnode, addnodes.only):
+ # only keep children if the only node matches the tags
+ if _only_node_keep_children(subnode, tags):
+ for child in subnode.children:
+ copy.append(_toctree_copy(
+ child, depth, maxdepth, collapse, tags, # type: ignore[type-var]
+ ))
+ elif isinstance(subnode, (nodes.reference, nodes.title)):
+ # deep copy references and captions
+ sub_node_copy = subnode.copy()
+ sub_node_copy.children = [child.deepcopy() for child in subnode.children]
+ for child in sub_node_copy.children:
+ child.parent = sub_node_copy
+ copy.append(sub_node_copy)
+ else:
+ msg = f'Unexpected node type {subnode.__class__.__name__!r}!'
+ raise ValueError(msg)
+ return copy
+
+
+def _get_toctree_ancestors(
+ toctree_includes: dict[str, list[str]], docname: str,
+) -> Set[str]:
+ parent: dict[str, str] = {}
+ for p, children in toctree_includes.items():
+ parent |= dict.fromkeys(children, p)
+ ancestors: list[str] = []
+ d = docname
+ while d in parent and d not in ancestors:
+ ancestors.append(d)
+ d = parent[d]
+ # use dict keys for ordered set operations
+ return dict.fromkeys(ancestors).keys()
+
+
+class TocTree:
+ def __init__(self, env: BuildEnvironment) -> None:
+ self.env = env
+
+ def note(self, docname: str, toctreenode: addnodes.toctree) -> None:
+ note_toctree(self.env, docname, toctreenode)
+
+ def resolve(self, docname: str, builder: Builder, toctree: addnodes.toctree,
+ prune: bool = True, maxdepth: int = 0, titles_only: bool = False,
+ collapse: bool = False, includehidden: bool = False) -> Element | None:
+ return _resolve_toctree(
+ self.env, docname, builder, toctree,
+ prune=prune,
+ maxdepth=maxdepth,
+ titles_only=titles_only,
+ collapse=collapse,
+ includehidden=includehidden,
+ )
+
+ def get_toctree_ancestors(self, docname: str) -> list[str]:
+ return [*_get_toctree_ancestors(self.env.toctree_includes, docname)]
+
+ def get_toc_for(self, docname: str, builder: Builder) -> Node:
+ return document_toc(self.env, docname, self.env.app.builder.tags)
+
+ def get_toctree_for(
+ self, docname: str, builder: Builder, collapse: bool, **kwargs: Any,
+ ) -> Element | None:
+ return global_toctree_for_doc(self.env, docname, builder, collapse=collapse, **kwargs)
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,
+ }