diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 17:25:40 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-15 17:25:40 +0000 |
commit | cf7da1843c45a4c2df7a749f7886a2d2ba0ee92a (patch) | |
tree | 18dcde1a8d1f5570a77cd0c361de3b490d02c789 /sphinx/environment/__init__.py | |
parent | Initial commit. (diff) | |
download | sphinx-upstream/7.2.6.tar.xz sphinx-upstream/7.2.6.zip |
Adding upstream version 7.2.6.upstream/7.2.6
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'sphinx/environment/__init__.py')
-rw-r--r-- | sphinx/environment/__init__.py | 779 |
1 files changed, 779 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) |