diff options
Diffstat (limited to 'sphinx/environment')
-rw-r--r-- | sphinx/environment/__init__.py | 779 | ||||
-rw-r--r-- | sphinx/environment/adapters/__init__.py | 1 | ||||
-rw-r--r-- | sphinx/environment/adapters/asset.py | 15 | ||||
-rw-r--r-- | sphinx/environment/adapters/indexentries.py | 187 | ||||
-rw-r--r-- | sphinx/environment/adapters/toctree.py | 520 | ||||
-rw-r--r-- | sphinx/environment/collectors/__init__.py | 72 | ||||
-rw-r--r-- | sphinx/environment/collectors/asset.py | 147 | ||||
-rw-r--r-- | sphinx/environment/collectors/dependencies.py | 57 | ||||
-rw-r--r-- | sphinx/environment/collectors/metadata.py | 70 | ||||
-rw-r--r-- | sphinx/environment/collectors/title.py | 61 | ||||
-rw-r--r-- | sphinx/environment/collectors/toctree.py | 355 |
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, + } |