diff --git a/doc/extdev/envapi.rst b/doc/extdev/envapi.rst index 971e506d093..d57374448cc 100644 --- a/doc/extdev/envapi.rst +++ b/doc/extdev/envapi.rst @@ -47,6 +47,52 @@ Build environment API .. autoattribute:: parser + **Per-document attributes** + + .. attribute:: current_document + + Temporary data storage while reading a document. + + Extensions may use the mapping interface provided by + ``env.current_document`` to store data relating to the current document, + but should use a unique prefix to avoid name clashes. + + .. important:: + Only the following attributes constitute the public API. + The type itself and any methods or other attributes remain private, + experimental, and will be changed or removed without notice. + + .. attribute:: current_document.docname + :type: str + + The document name ('docname') for the current document. + + .. attribute:: current_document.default_role + :type: str + + The default role for the current document. + Set by the :dudir:`default-role` directive. + + .. attribute:: current_document.default_domain + :type: Domain | None + + The default domain for the current document. + Set by the :rst:dir:`default-domain` directive. + + .. attribute:: current_document.highlight_language + :type: str + + The default language for syntax highlighting. + Set by the :rst:dir:`highlight` directive to override + the :confval:`highlight_language` config value. + + .. attribute:: current_document._parser + :type: Parser | None + + *This attribute is experimental and may be changed without notice.* + + The parser being used to parse the current document. + **Utility methods** .. automethod:: doc2path diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 7c9b9cb172c..71f89cdcc50 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -13,7 +13,12 @@ from docutils import nodes from docutils.utils import DependencyList -from sphinx.environment import CONFIG_CHANGED_REASON, CONFIG_OK, BuildEnvironment +from sphinx.environment import ( + CONFIG_CHANGED_REASON, + CONFIG_OK, + BuildEnvironment, + _CurrentDocument, +) from sphinx.environment.adapters.asset import ImageAdapter from sphinx.errors import SphinxError from sphinx.locale import __ @@ -620,7 +625,7 @@ def read_doc(self, docname: str, *, _cache: bool = True) -> None: filename = str(self.env.doc2path(docname)) filetype = get_filetype(self.app.config.source_suffix, filename) publisher = self.app.registry.get_publisher(self.app, filetype) - self.env.temp_data['_parser'] = publisher.parser + self.env.current_document._parser = publisher.parser # record_dependencies is mutable even though it is in settings, # explicitly re-initialise for each document publisher.settings.record_dependencies = DependencyList() @@ -640,7 +645,7 @@ def read_doc(self, docname: str, *, _cache: bool = True) -> None: self.env.all_docs[docname] = time.time_ns() // 1_000 # cleanup - self.env.temp_data.clear() + self.env.current_document = _CurrentDocument() self.env.ref_context.clear() self.write_doctree(docname, doctree, _cache=_cache) diff --git a/sphinx/directives/__init__.py b/sphinx/directives/__init__.py index de0dc45beec..1488d4ee391 100644 --- a/sphinx/directives/__init__.py +++ b/sphinx/directives/__init__.py @@ -284,9 +284,9 @@ def run(self) -> list[Node]: # needed for association of version{added,changed} directives object_name: ObjDescT = self.names[0] if isinstance(object_name, tuple): - self.env.temp_data['object'] = str(object_name[0]) + self.env.current_document.obj_desc_name = str(object_name[0]) else: - self.env.temp_data['object'] = str(object_name) + self.env.current_document.obj_desc_name = str(object_name) self.before_content() content_children = self.parse_content_to_nodes(allow_section_headings=True) content_node = addnodes.desc_content('', *content_children) @@ -296,7 +296,7 @@ def run(self) -> list[Node]: 'object-description-transform', self.domain, self.objtype, content_node ) DocFieldTransformer(self).transform_all(content_node) - self.env.temp_data['object'] = '' + self.env.current_document.obj_desc_name = '' self.after_content() if node['no-typesetting']: @@ -335,7 +335,7 @@ def run(self) -> list[Node]: ) if role: docutils.register_role('', role) # type: ignore[arg-type] - self.env.temp_data['default_role'] = role_name + self.env.current_document.default_role = role_name else: literal_block = nodes.literal_block(self.block_text, self.block_text) reporter = self.state.reporter @@ -362,13 +362,8 @@ class DefaultDomain(SphinxDirective): def run(self) -> list[Node]: domain_name = self.arguments[0].lower() - # if domain_name not in env.domains: - # # try searching by label - # for domain in env.domains.sorted(): - # if domain.label.lower() == domain_name: - # domain_name = domain.name - # break - self.env.temp_data['default_domain'] = self.env.domains.get(domain_name) + default_domain = self.env.domains.get(domain_name) + self.env.current_document.default_domain = default_domain return [] diff --git a/sphinx/directives/code.py b/sphinx/directives/code.py index b68f6e1a729..00925debe7d 100644 --- a/sphinx/directives/code.py +++ b/sphinx/directives/code.py @@ -48,7 +48,7 @@ def run(self) -> list[Node]: linenothreshold = self.options.get('linenothreshold', sys.maxsize) force = 'force' in self.options - self.env.temp_data['highlight_language'] = language + self.env.current_document.highlight_language = language return [ addnodes.highlightlang( lang=language, force=force, linenothreshold=linenothreshold @@ -159,8 +159,9 @@ def run(self) -> list[Node]: # no highlight language specified. Then this directive refers the current # highlight setting via ``highlight`` directive or ``highlight_language`` # configuration. - literal['language'] = self.env.temp_data.get( - 'highlight_language', self.config.highlight_language + literal['language'] = ( + self.env.current_document.highlight_language + or self.config.highlight_language ) extra_args = literal['highlight_args'] = {} if hl_lines is not None: diff --git a/sphinx/directives/patches.py b/sphinx/directives/patches.py index 37144abacdd..65f14471435 100644 --- a/sphinx/directives/patches.py +++ b/sphinx/directives/patches.py @@ -115,8 +115,9 @@ def run(self) -> list[Node]: # no highlight language specified. Then this directive refers the current # highlight setting via ``highlight`` directive or ``highlight_language`` # configuration. - node['language'] = self.env.temp_data.get( - 'highlight_language', self.config.highlight_language + node['language'] = ( + self.env.current_document.highlight_language + or self.config.highlight_language ) if 'number-lines' in self.options: diff --git a/sphinx/domains/c/__init__.py b/sphinx/domains/c/__init__.py index f58e2e607ff..992d95b932d 100644 --- a/sphinx/domains/c/__init__.py +++ b/sphinx/domains/c/__init__.py @@ -220,19 +220,19 @@ def describe_signature( def run(self) -> list[Node]: env = self.state.document.settings.env # from ObjectDescription.run - if 'c:parent_symbol' not in env.temp_data: + if env.current_document.c_parent_symbol is None: root = env.domaindata['c']['root_symbol'] - env.temp_data['c:parent_symbol'] = root + env.current_document.c_parent_symbol = root env.ref_context['c:parent_key'] = root.get_lookup_key() # When multiple declarations are made in the same directive # they need to know about each other to provide symbol lookup for function parameters. # We use last_symbol to store the latest added declaration in a directive. - env.temp_data['c:last_symbol'] = None + env.current_document.c_last_symbol = None return super().run() def handle_signature(self, sig: str, signode: TextElement) -> ASTDeclaration: - parent_symbol: Symbol = self.env.temp_data['c:parent_symbol'] + parent_symbol: Symbol = self.env.current_document.c_parent_symbol max_len = ( self.env.config.c_maximum_signature_line_length @@ -254,7 +254,7 @@ def handle_signature(self, sig: str, signode: TextElement) -> ASTDeclaration: # the possibly inner declarations. name = _make_phony_error_name() symbol = parent_symbol.add_name(name) - self.env.temp_data['c:last_symbol'] = symbol + self.env.current_document.c_last_symbol = symbol raise ValueError from e try: @@ -264,15 +264,15 @@ def handle_signature(self, sig: str, signode: TextElement) -> ASTDeclaration: # append the new declaration to the sibling list assert symbol.siblingAbove is None assert symbol.siblingBelow is None - symbol.siblingAbove = self.env.temp_data['c:last_symbol'] + symbol.siblingAbove = self.env.current_document.c_last_symbol if symbol.siblingAbove is not None: assert symbol.siblingAbove.siblingBelow is None symbol.siblingAbove.siblingBelow = symbol - self.env.temp_data['c:last_symbol'] = symbol + self.env.current_document.c_last_symbol = symbol except _DuplicateSymbolError as e: # Assume we are actually in the old symbol, # instead of the newly created duplicate. - self.env.temp_data['c:last_symbol'] = e.symbol + self.env.current_document.c_last_symbol = e.symbol msg = __( 'Duplicate C declaration, also defined at %s:%s.\n' "Declaration is '.. c:%s:: %s'." @@ -298,15 +298,15 @@ def handle_signature(self, sig: str, signode: TextElement) -> ASTDeclaration: return ast def before_content(self) -> None: - last_symbol: Symbol = self.env.temp_data['c:last_symbol'] + last_symbol: Symbol = self.env.current_document.c_last_symbol assert last_symbol - self.oldParentSymbol = self.env.temp_data['c:parent_symbol'] + self.oldParentSymbol = self.env.current_document.c_parent_symbol self.oldParentKey: LookupKey = self.env.ref_context['c:parent_key'] - self.env.temp_data['c:parent_symbol'] = last_symbol + self.env.current_document.c_parent_symbol = last_symbol self.env.ref_context['c:parent_key'] = last_symbol.get_lookup_key() def after_content(self) -> None: - self.env.temp_data['c:parent_symbol'] = self.oldParentSymbol + self.env.current_document.c_parent_symbol = self.oldParentSymbol self.env.ref_context['c:parent_key'] = self.oldParentKey @@ -410,8 +410,8 @@ def run(self) -> list[Node]: name = _make_phony_error_name() symbol = root_symbol.add_name(name) stack = [symbol] - self.env.temp_data['c:parent_symbol'] = symbol - self.env.temp_data['c:namespace_stack'] = stack + self.env.current_document.c_parent_symbol = symbol + self.env.current_document.c_namespace_stack = stack self.env.ref_context['c:parent_key'] = symbol.get_lookup_key() return [] @@ -435,14 +435,12 @@ def run(self) -> list[Node]: except DefinitionError as e: logger.warning(e, location=self.get_location()) name = _make_phony_error_name() - old_parent = self.env.temp_data.get('c:parent_symbol', None) + old_parent = self.env.current_document.c_parent_symbol if not old_parent: old_parent = self.env.domaindata['c']['root_symbol'] symbol = old_parent.add_name(name) - stack = self.env.temp_data.get('c:namespace_stack', []) - stack.append(symbol) - self.env.temp_data['c:parent_symbol'] = symbol - self.env.temp_data['c:namespace_stack'] = stack + self.env.current_document.c_namespace_stack.append(symbol) + self.env.current_document.c_parent_symbol = symbol self.env.ref_context['c:parent_key'] = symbol.get_lookup_key() return [] @@ -455,21 +453,19 @@ class CNamespacePopObject(SphinxDirective): option_spec: ClassVar[OptionSpec] = {} def run(self) -> list[Node]: - stack = self.env.temp_data.get('c:namespace_stack', None) - if not stack or len(stack) == 0: + stack = self.env.current_document.c_namespace_stack + if len(stack) == 0: logger.warning( 'C namespace pop on empty stack. Defaulting to global scope.', location=self.get_location(), ) - stack = [] else: stack.pop() if len(stack) > 0: symbol = stack[-1] else: symbol = self.env.domaindata['c']['root_symbol'] - self.env.temp_data['c:parent_symbol'] = symbol - self.env.temp_data['c:namespace_stack'] = stack + self.env.current_document.c_parent_symbol = symbol self.env.ref_context['c:parent_key'] = symbol.get_lookup_key() return [] @@ -488,9 +484,9 @@ def __init__( self.aliasOptions = aliasOptions self.document = document if env is not None: - if 'c:parent_symbol' not in env.temp_data: + if env.current_document.c_parent_symbol is None: root = env.domaindata['c']['root_symbol'] - env.temp_data['c:parent_symbol'] = root + env.current_document.c_parent_symbol = root env.ref_context['c:parent_key'] = root.get_lookup_key() self.parentKey = env.ref_context['c:parent_key'] else: @@ -735,7 +731,7 @@ def run(self) -> tuple[list[Node], list[system_message]]: # see below node = addnodes.desc_inline('c', text, text, classes=[self.class_type]) return [node], [] - parent_symbol = self.env.temp_data.get('c:parent_symbol', None) + parent_symbol = self.env.current_document.c_parent_symbol if parent_symbol is None: parent_symbol = self.env.domaindata['c']['root_symbol'] # ...most if not all of these classes should really apply to the individual references, diff --git a/sphinx/domains/changeset.py b/sphinx/domains/changeset.py index 4d3e81da122..c54580d39a0 100644 --- a/sphinx/domains/changeset.py +++ b/sphinx/domains/changeset.py @@ -133,7 +133,7 @@ def changesets(self) -> dict[str, list[ChangeSet]]: def note_changeset(self, node: addnodes.versionmodified) -> None: version = node['version'] module = self.env.ref_context.get('py:module') - objname = self.env.temp_data.get('object', '') + objname = self.env.current_document.obj_desc_name changeset = ChangeSet( node['type'], self.env.docname, diff --git a/sphinx/domains/cpp/__init__.py b/sphinx/domains/cpp/__init__.py index 7c84aac727a..b696e529b43 100644 --- a/sphinx/domains/cpp/__init__.py +++ b/sphinx/domains/cpp/__init__.py @@ -309,9 +309,9 @@ def describe_signature( def run(self) -> list[Node]: env = self.state.document.settings.env # from ObjectDescription.run - if 'cpp:parent_symbol' not in env.temp_data: + if env.current_document.cpp_parent_symbol is None: root = env.domaindata['cpp']['root_symbol'] - env.temp_data['cpp:parent_symbol'] = root + env.current_document.cpp_parent_symbol = root env.ref_context['cpp:parent_key'] = root.get_lookup_key() # The lookup keys assume that no nested scopes exists inside overloaded functions. @@ -325,7 +325,7 @@ def run(self) -> list[Node]: # :cpp:any:`boom` # # So we disallow any signatures inside functions. - parent_symbol = env.temp_data['cpp:parent_symbol'] + parent_symbol = env.current_document.cpp_parent_symbol parent_decl = parent_symbol.declaration if parent_decl is not None and parent_decl.objectType == 'function': msg = ( @@ -336,16 +336,16 @@ def run(self) -> list[Node]: logger.warning(msg, location=self.get_location()) name = _make_phony_error_name() symbol = parent_symbol.add_name(name) - env.temp_data['cpp:last_symbol'] = symbol + env.current_document.cpp_last_symbol = symbol return [] # When multiple declarations are made in the same directive # they need to know about each other to provide symbol lookup for function parameters. # We use last_symbol to store the latest added declaration in a directive. - env.temp_data['cpp:last_symbol'] = None + env.current_document.cpp_last_symbol = None return super().run() def handle_signature(self, sig: str, signode: desc_signature) -> ASTDeclaration: - parent_symbol: Symbol = self.env.temp_data['cpp:parent_symbol'] + parent_symbol: Symbol = self.env.current_document.cpp_parent_symbol max_len = ( self.env.config.cpp_maximum_signature_line_length @@ -367,7 +367,7 @@ def handle_signature(self, sig: str, signode: desc_signature) -> ASTDeclaration: # the possibly inner declarations. name = _make_phony_error_name() symbol = parent_symbol.add_name(name) - self.env.temp_data['cpp:last_symbol'] = symbol + self.env.current_document.cpp_last_symbol = symbol raise ValueError from e try: @@ -377,15 +377,15 @@ def handle_signature(self, sig: str, signode: desc_signature) -> ASTDeclaration: # append the new declaration to the sibling list assert symbol.siblingAbove is None assert symbol.siblingBelow is None - symbol.siblingAbove = self.env.temp_data['cpp:last_symbol'] + symbol.siblingAbove = self.env.current_document.cpp_last_symbol if symbol.siblingAbove is not None: assert symbol.siblingAbove.siblingBelow is None symbol.siblingAbove.siblingBelow = symbol - self.env.temp_data['cpp:last_symbol'] = symbol + self.env.current_document.cpp_last_symbol = symbol except _DuplicateSymbolError as e: # Assume we are actually in the old symbol, # instead of the newly created duplicate. - self.env.temp_data['cpp:last_symbol'] = e.symbol + self.env.current_document.cpp_last_symbol = e.symbol msg = __( 'Duplicate C++ declaration, also defined at %s:%s.\n' "Declaration is '.. cpp:%s:: %s'." @@ -412,27 +412,28 @@ def handle_signature(self, sig: str, signode: desc_signature) -> ASTDeclaration: return ast def before_content(self) -> None: - last_symbol: Symbol = self.env.temp_data['cpp:last_symbol'] + last_symbol: Symbol = self.env.current_document.cpp_last_symbol assert last_symbol - self.oldParentSymbol = self.env.temp_data['cpp:parent_symbol'] + self.oldParentSymbol = self.env.current_document.cpp_parent_symbol self.oldParentKey: LookupKey = self.env.ref_context['cpp:parent_key'] - self.env.temp_data['cpp:parent_symbol'] = last_symbol + self.env.current_document.cpp_parent_symbol = last_symbol self.env.ref_context['cpp:parent_key'] = last_symbol.get_lookup_key() - self.env.temp_data['cpp:domain_name'] = ( - *self.env.temp_data.get('cpp:domain_name', ()), + self.env.current_document.cpp_domain_name = ( + *self.env.current_document.cpp_domain_name, last_symbol.identOrOp._stringify(str), ) def after_content(self) -> None: - temp_data = self.env.temp_data - temp_data['cpp:parent_symbol'] = self.oldParentSymbol + self.env.current_document.cpp_parent_symbol = self.oldParentSymbol self.env.ref_context['cpp:parent_key'] = self.oldParentKey - temp_data['cpp:domain_name'] = temp_data['cpp:domain_name'][:-1] + cpp_domain_name = self.env.current_document.cpp_domain_name + self.env.current_document.cpp_domain_name = cpp_domain_name[:-1] def _object_hierarchy_parts(self, sig_node: desc_signature) -> tuple[str, ...]: + last_symbol: Symbol = self.env.current_document.cpp_last_symbol return tuple( s.identOrOp._stringify(str) - for s in self.env.temp_data['cpp:last_symbol'].get_full_nested_name().names + for s in last_symbol.get_full_nested_name().names ) def _toc_entry_name(self, sig_node: desc_signature) -> str: @@ -448,7 +449,7 @@ def _toc_entry_name(self, sig_node: desc_signature) -> str: *parents, name = sig_node['_toc_parts'] if config.toc_object_entries_show_parents == 'domain': return '::'.join(( - *self.env.temp_data.get('cpp:domain_name', ()), + *self.env.current_document.cpp_domain_name, name + parens, )) if config.toc_object_entries_show_parents == 'hide': @@ -555,8 +556,8 @@ def run(self) -> list[Node]: ast = ASTNamespace(name, None) symbol = root_symbol.add_name(ast.nestedName, ast.templatePrefix) stack = [symbol] - self.env.temp_data['cpp:parent_symbol'] = symbol - self.env.temp_data['cpp:namespace_stack'] = stack + self.env.current_document.cpp_parent_symbol = symbol + self.env.current_document.cpp_namespace_stack = stack self.env.ref_context['cpp:parent_key'] = symbol.get_lookup_key() return [] @@ -581,14 +582,12 @@ def run(self) -> list[Node]: logger.warning(e, location=self.get_location()) name = _make_phony_error_name() ast = ASTNamespace(name, None) - old_parent = self.env.temp_data.get('cpp:parent_symbol', None) + old_parent = self.env.current_document.cpp_parent_symbol if not old_parent: old_parent = self.env.domaindata['cpp']['root_symbol'] symbol = old_parent.add_name(ast.nestedName, ast.templatePrefix) - stack = self.env.temp_data.get('cpp:namespace_stack', []) - stack.append(symbol) - self.env.temp_data['cpp:parent_symbol'] = symbol - self.env.temp_data['cpp:namespace_stack'] = stack + self.env.current_document.cpp_namespace_stack.append(symbol) + self.env.current_document.cpp_parent_symbol = symbol self.env.ref_context['cpp:parent_key'] = symbol.get_lookup_key() return [] @@ -601,21 +600,19 @@ class CPPNamespacePopObject(SphinxDirective): option_spec: ClassVar[OptionSpec] = {} def run(self) -> list[Node]: - stack = self.env.temp_data.get('cpp:namespace_stack', None) - if not stack or len(stack) == 0: + stack = self.env.current_document.cpp_namespace_stack + if len(stack) == 0: logger.warning( 'C++ namespace pop on empty stack. Defaulting to global scope.', location=self.get_location(), ) - stack = [] else: stack.pop() if len(stack) > 0: symbol = stack[-1] else: symbol = self.env.domaindata['cpp']['root_symbol'] - self.env.temp_data['cpp:parent_symbol'] = symbol - self.env.temp_data['cpp:namespace_stack'] = stack + self.env.current_document.cpp_parent_symbol = symbol self.env.ref_context['cpp:parent_key'] = symbol.get_lookup_key() return [] @@ -632,9 +629,9 @@ def __init__( self.sig = sig self.aliasOptions = aliasOptions if env is not None: - if 'cpp:parent_symbol' not in env.temp_data: + if env.current_document.cpp_parent_symbol is None: root = env.domaindata['cpp']['root_symbol'] - env.temp_data['cpp:parent_symbol'] = root + env.current_document.cpp_parent_symbol = root env.ref_context['cpp:parent_key'] = root.get_lookup_key() self.parentKey = env.ref_context['cpp:parent_key'] else: @@ -844,7 +841,7 @@ def run(self) -> list[Node]: self.before_content() content_node = addnodes.desc_content('', *self.parse_content_to_nodes()) node.append(content_node) - self.env.temp_data['object'] = None + self.env.current_document.obj_desc_name = '' self.after_content() return [node] @@ -912,7 +909,7 @@ def run(self) -> tuple[list[Node], list[system_message]]: # see below node = addnodes.desc_inline('cpp', text, text, classes=[self.class_type]) return [node], [] - parent_symbol = self.env.temp_data.get('cpp:parent_symbol', None) + parent_symbol = self.env.current_document.cpp_parent_symbol if parent_symbol is None: parent_symbol = self.env.domaindata['cpp']['root_symbol'] # ...most if not all of these classes should really apply to the individual references, diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py index e73ecd7a511..74b75d6921b 100644 --- a/sphinx/environment/__init__.py +++ b/sphinx/environment/__init__.py @@ -6,8 +6,8 @@ import os import pickle from collections import defaultdict -from copy import copy -from typing import TYPE_CHECKING +from copy import deepcopy +from typing import TYPE_CHECKING, Final from sphinx import addnodes from sphinx.domains._domains_container import _DomainsContainer @@ -42,6 +42,8 @@ from sphinx.builders import Builder from sphinx.config import Config from sphinx.domains import Domain + from sphinx.domains.c._symbol import Symbol as CSymbol + from sphinx.domains.cpp._symbol import Symbol as CPPSymbol from sphinx.events import EventManager from sphinx.project import Project from sphinx.util._pathlib import _StrPath @@ -196,10 +198,10 @@ def __init__(self, app: Sphinx) -> None: self.original_image_uri: dict[_StrPath, str] = {} # temporary data storage while reading a document - self.temp_data: dict[str, Any] = {} + self.current_document: _CurrentDocument = _CurrentDocument() # 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 + # this is similar to ``self.current_document``, + # but will for example be copied to attributes of "any" cross references self.ref_context: dict[str, Any] = {} # search index data @@ -426,7 +428,13 @@ def relfn2path(self, filename: str, docname: str | None = None) -> tuple[str, st if filename.startswith('/'): abs_fn = (self.srcdir / filename[1:]).resolve() else: - doc_dir = self.doc2path(docname or self.docname, base=False).parent + if not docname: + if self.docname: + docname = self.docname + else: + msg = 'docname' + raise KeyError(msg) + doc_dir = self.doc2path(docname, base=False).parent abs_fn = (self.srcdir / doc_dir / filename).resolve() rel_fn = _relative_path(abs_fn, self.srcdir) @@ -558,32 +566,42 @@ def check_dependents(self, app: Sphinx, already: set[str]) -> Iterator[str]: 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) + self.current_document = _CurrentDocument( + docname=docname, + # defaults to the global default, but can be re-set in a document + default_role=self.config.default_role, + default_domain=self.domains.get(self.config.primary_domain), + ) # utilities to use while reading a document + @property + def temp_data(self) -> _CurrentDocument: + """Returns the temporary data storage for the current document. + + Kept for backwards compatibility. + """ + return self.current_document + @property def docname(self) -> str: """Returns the docname of the document currently being parsed.""" - return self.temp_data['docname'] + return self.current_document.docname @property def parser(self) -> Parser: """Returns the parser being used for to parse the current document.""" - return self.temp_data['_parser'] + if (parser := self.current_document._parser) is not None: + return parser + msg = 'parser' + raise KeyError(msg) 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 + return self.current_document.new_serial_number(category) def note_dependency( self, filename: str | os.PathLike[str], *, docname: str | None = None @@ -726,17 +744,19 @@ def resolve_references( def apply_post_transforms(self, doctree: nodes.document, docname: str) -> None: """Apply all post-transforms.""" + backup = self.current_document + new = deepcopy(backup) + new.docname = docname try: # set env.docname during applying post-transforms - backup = copy(self.temp_data) - self.temp_data['docname'] = docname + self.current_document = new transformer = SphinxTransformer(doctree) transformer.set_environment(self) transformer.add_transforms(self.app.registry.get_post_transforms()) transformer.apply_transforms() finally: - self.temp_data = backup + self.current_document = backup # allow custom references to be resolved self.events.emit('doctree-resolved', doctree, docname) @@ -846,3 +866,230 @@ def _check_toc_parents(toctree_includes: dict[str, list[str]]) -> None: type='toc', subtype='multiple_toc_parents', ) + + +class _CurrentDocument: + """Temporary data storage while reading a document. + + This class is only for internal use. Please don't use this in your extensions. + It will be removed or changed without notice. + The only stable API is via ``env.current_document``. + """ + + __slots__ = ( + '_parser', + '_serial_numbers', + '_extension_data', + 'autodoc_annotations', + 'autodoc_class', + 'autodoc_module', + 'c_last_symbol', + 'c_namespace_stack', + 'c_parent_symbol', + 'cpp_domain_name', + 'cpp_last_symbol', + 'cpp_namespace_stack', + 'cpp_parent_symbol', + 'default_domain', + 'default_role', + 'docname', + 'highlight_language', + 'obj_desc_name', + 'reading_started_at', + ) + + # Map of old-style temp_data keys to _CurrentDocument attributes + __attr_map: Final = { + '_parser': '_parser', + 'annotations': 'autodoc_annotations', + 'autodoc:class': 'autodoc_class', + 'autodoc:module': 'autodoc_module', + 'c:last_symbol': 'c_last_symbol', + 'c:namespace_stack': 'c_namespace_stack', + 'c:parent_symbol': 'c_parent_symbol', + 'cpp:domain_name': 'cpp_domain_name', + 'cpp:last_symbol': 'cpp_last_symbol', + 'cpp:namespace_stack': 'cpp_namespace_stack', + 'cpp:parent_symbol': 'cpp_parent_symbol', + 'default_domain': 'default_domain', + 'default_role': 'default_role', + 'docname': 'docname', + 'highlight_language': 'highlight_language', + 'object': 'obj_desc_name', + 'started_at': 'reading_started_at', + } + + # Attributes that should reset to None if popped. + __attr_default_none: Final = frozenset({ + '_parser', + 'c:last_symbol', + 'c:parent_symbol', + 'cpp:last_symbol', + 'cpp:parent_symbol', + 'default_domain', + }) + + def __init__( + self, + *, + docname: str = '', + default_role: str = '', + default_domain: Domain | None = None, + ) -> None: + #: The docname of the document currently being parsed. + self.docname: str = docname + + #: The default role for the current document. + #: Set by the ``.. default-role::`` directive. + self.default_role: str = default_role + + #: The default domain for the current document. + #: Set by the ``.. default-domain::`` directive. + self.default_domain: Domain | None = default_domain + + #: The parser being used to parse the current document. + self._parser: Parser | None = None + + #: The default language for syntax highlighting. + #: Set by the ``.. highlight::`` directive to override + #: the ``highlight_language`` config value. + self.highlight_language: str = '' + + #: The current object's name. + #: Used in the Changes builder. + self.obj_desc_name: str = '' + + #: Records type hints of Python objects in the current document. + #: Used in ``sphinx.ext.autodoc.typehints``. + #: Maps object names to maps of attribute names -> type hints. + self.autodoc_annotations: dict[str, dict[str, str]] = {} + + #: The current Python class name. + #: Used in ``sphinx.ext.autodoc``. + self.autodoc_class: str = '' + + #: The current Python module name. + #: Used in ``sphinx.ext.autodoc``. + self.autodoc_module: str = '' + + #: The most-recently added declaration in a directive. + #: Used in the C Domain. + self.c_last_symbol: CSymbol | None = None + + #: The stack of namespace scopes, altered by the ``.. c:namespace::`` + #: and ``.. c:namespace-(push|pop)::``directives. + #: Used in the C Domain. + self.c_namespace_stack: list[CSymbol] = [] + + #: The parent declaration. + #: Used in the C Domain. + self.c_parent_symbol: CSymbol | None = None + + #: A stack of the string representation of declarations, + #: used to format the table of contents entry. + #: Used in the C++ Domain. + self.cpp_domain_name: tuple[str, ...] = () + + #: The most-recently added declaration in a directive. + #: Used in the C++ Domain. + self.cpp_last_symbol: CPPSymbol | None = None + + #: The stack of namespace scopes, altered by the ``.. cpp:namespace::`` + #: and ``.. cpp:namespace-(push|pop)::``directives. + #: Used in the C++ Domain. + self.cpp_namespace_stack: list[CPPSymbol] = [] + + #: The parent declaration. + #: Used in the C++ Domain. + self.cpp_parent_symbol: CPPSymbol | None = None + + #: Records the time when reading begain for the current document. + #: Used in ``sphinx.ext.duration``. + self.reading_started_at: float = 0.0 + + # Used for generating unique serial numbers. + self._serial_numbers: dict[str, int] = {} + + # Stores properties relating to the current document set by extensions. + self._extension_data: dict[str, Any] = {} + + def new_serial_number(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 & category. + """ + current = self._serial_numbers.get(category, 0) + self._serial_numbers[category] = current + 1 + return current + + # Mapping interface: + + def __getitem__(self, item: str) -> Any: + if item in self.__attr_map: + return getattr(self, self.__attr_map[item]) + return self._extension_data[item] + + def __setitem__(self, key: str, value: Any) -> None: + if key in self.__attr_map: + setattr(self, self.__attr_map[key], value) + else: + self._extension_data[key] = value + + def __delitem__(self, key: str) -> None: + self.pop(key, default=None) + + def __contains__(self, item: str) -> bool: + if item in {'c:parent_symbol', 'cpp:parent_symbol'}: + return getattr(self, item) is not None + return item in self.__attr_map or item in self._extension_data + + def __iter__(self) -> Iterator[str]: + return iter(self.keys()) + + def __len__(self) -> int: + return len(self.__attr_map) + len(self._extension_data) + + def keys(self) -> Iterable[str]: + return frozenset(self.__attr_map.keys() | self._extension_data.keys()) + + def items(self) -> Iterable[tuple[str, Any]]: + for key in self.keys(): + yield key, self[key] + + def values(self) -> Iterable[Any]: + for key in self.keys(): + yield self[key] + + def get(self, key: str, default: Any | None = None) -> Any | None: + try: + return self[key] + except KeyError: + return default + + __sentinel = object() + + def pop(self, key: str, default: Any | None = __sentinel) -> Any | None: + if key in self.__attr_map: + # the keys in __attr_map always exist, so ``default`` is ignored + value = getattr(self, self.__attr_map[key]) + if key in self.__attr_default_none: + default = None + else: + default = type(value)() # set key to type's default + setattr(self, self.__attr_map[key], default) + return value + if default is self.__sentinel: + return self._extension_data.pop(key) + return self._extension_data.pop(key, default) + + def setdefault(self, key: str, default: Any | None = None) -> Any | None: + return self._extension_data.setdefault(key, default) + + def clear(self) -> None: + _CurrentDocument.__init__(self) # NoQA: PLC2801 + + def update(self, other: Iterable[tuple[str, Any]] = (), /, **kwargs: Any) -> None: + other_dict = dict(other) if not isinstance(other, dict) else other + for dct in other_dict, kwargs: + for key, value in dct.items(): + self[key] = value diff --git a/sphinx/ext/autodoc/__init__.py b/sphinx/ext/autodoc/__init__.py index accb4c37f8a..f6ae4890fad 100644 --- a/sphinx/ext/autodoc/__init__.py +++ b/sphinx/ext/autodoc/__init__.py @@ -878,9 +878,9 @@ def document_members(self, all_members: bool = False) -> None: *self.options.members*. """ # set current namespace for finding members - self.env.temp_data['autodoc:module'] = self.modname + self.env.current_document.autodoc_module = self.modname if self.objpath: - self.env.temp_data['autodoc:class'] = self.objpath[0] + self.env.current_document.autodoc_class = self.objpath[0] want_all = ( all_members or self.options.inherited_members or self.options.members is ALL @@ -918,8 +918,8 @@ def document_members(self, all_members: bool = False) -> None: ) # reset current objects - self.env.temp_data['autodoc:module'] = None - self.env.temp_data['autodoc:class'] = None + self.env.current_document.autodoc_module = '' + self.env.current_document.autodoc_class = '' def sort_members( self, documenters: list[tuple[Documenter, bool]], order: str @@ -1261,7 +1261,7 @@ def resolve_name( # if documenting a toplevel object without explicit module, # it can be contained in another auto directive ... - modname = self.env.temp_data.get('autodoc:module') + modname = self.env.current_document.autodoc_module # ... or in the scope of a module directive if not modname: modname = self.env.ref_context.get('py:module') @@ -1287,19 +1287,18 @@ def resolve_name( # if documenting a class-level object without path, # there must be a current class, either from a parent # auto directive ... - mod_cls_ = self.env.temp_data.get('autodoc:class') + mod_cls = self.env.current_document.autodoc_class # ... or from a class directive - if mod_cls_ is None: - mod_cls_ = self.env.ref_context.get('py:class') - # ... if still None, there's no way to know - if mod_cls_ is None: + if not mod_cls: + mod_cls = self.env.ref_context.get('py:class', '') + # ... if still falsy, there's no way to know + if not mod_cls: return None, [] - mod_cls = mod_cls_ modname, sep, cls = mod_cls.rpartition('.') parents = [cls] # if the module name is still missing, get it like above if not modname: - modname = self.env.temp_data.get('autodoc:module') + modname = self.env.current_document.autodoc_module if not modname: modname = self.env.ref_context.get('py:module') # ... else, it stays None, which means invalid diff --git a/sphinx/ext/autodoc/typehints.py b/sphinx/ext/autodoc/typehints.py index 3fd64856d6c..f2b5b2567a9 100644 --- a/sphinx/ext/autodoc/typehints.py +++ b/sphinx/ext/autodoc/typehints.py @@ -38,8 +38,8 @@ def record_typehints( try: if callable(obj): - annotations = app.env.temp_data.setdefault('annotations', {}) - annotation = annotations.setdefault(name, {}) + current_document = app.env.current_document + annotation = current_document.autodoc_annotations.setdefault(name, {}) sig = inspect.signature(obj, type_aliases=app.config.autodoc_type_aliases) for param in sig.parameters.values(): if param.annotation is not param.empty: @@ -71,7 +71,7 @@ def merge_typehints( # signature node does not have valid context info for the target object return - annotations = app.env.temp_data.get('annotations', {}) + annotations = app.env.current_document.autodoc_annotations if annotations.get(fullname, {}): field_lists = [n for n in contentnode if isinstance(n, nodes.field_list)] if field_lists == []: diff --git a/sphinx/ext/duration.py b/sphinx/ext/duration.py index 77940c2bb05..1cf3f7b58d4 100644 --- a/sphinx/ext/duration.py +++ b/sphinx/ext/duration.py @@ -66,13 +66,12 @@ def on_builder_inited(app: Sphinx) -> None: def on_source_read(app: Sphinx, docname: str, content: list[str]) -> None: """Start to measure reading duration.""" - app.env.temp_data['started_at'] = time.monotonic() + app.env.current_document.reading_started_at = time.monotonic() def on_doctree_read(app: Sphinx, doctree: nodes.document) -> None: """Record a reading duration.""" - started_at = app.env.temp_data['started_at'] - duration = time.monotonic() - started_at + duration = time.monotonic() - app.env.current_document.reading_started_at domain = app.env.domains['duration'] domain.note_reading_duration(duration) diff --git a/sphinx/ext/intersphinx/_resolve.py b/sphinx/ext/intersphinx/_resolve.py index 94101588bfa..09a7a057cc8 100644 --- a/sphinx/ext/intersphinx/_resolve.py +++ b/sphinx/ext/intersphinx/_resolve.py @@ -398,7 +398,7 @@ def run(self) -> tuple[list[Node], list[system_message]]: # the user did not specify a domain, # so we check first the default (if available) then standard domains domains: list[Domain] = [] - if default_domain := self.env.temp_data.get('default_domain'): + if default_domain := self.env.current_document.default_domain: domains.append(default_domain) if ( std_domain := self.env.domains.standard_domain @@ -505,7 +505,7 @@ def get_role_name(self, name: str) -> tuple[str, str] | None: names = name.split(':') if len(names) == 1: # role - default_domain = self.env.temp_data.get('default_domain') + default_domain = self.env.current_document.default_domain domain = default_domain.name if default_domain else None role = names[0] elif len(names) == 2: diff --git a/sphinx/testing/restructuredtext.py b/sphinx/testing/restructuredtext.py index 7231c464c72..38fe96d79b4 100644 --- a/sphinx/testing/restructuredtext.py +++ b/sphinx/testing/restructuredtext.py @@ -17,7 +17,7 @@ def parse(app: Sphinx, text: str, docname: str = 'index') -> nodes.document: """Parse a string as reStructuredText with Sphinx application.""" try: - app.env.temp_data['docname'] = docname + app.env.current_document.docname = docname reader = SphinxStandaloneReader() reader.setup(app) parser = RSTParser() @@ -37,4 +37,4 @@ def parse(app: Sphinx, text: str, docname: str = 'index') -> nodes.document: }, ) finally: - app.env.temp_data.pop('docname', None) + app.env.current_document.docname = '' diff --git a/sphinx/util/docutils.py b/sphinx/util/docutils.py index d5d74b9deb9..ba1041d67ea 100644 --- a/sphinx/util/docutils.py +++ b/sphinx/util/docutils.py @@ -285,7 +285,7 @@ def lookup_domain_element(self, type: str, name: str) -> Any: ) # else look in the default domain else: - def_domain = self.env.temp_data.get('default_domain') + def_domain = self.env.current_document.default_domain if def_domain is not None: element = getattr(def_domain, type)(name) if element is not None: @@ -587,7 +587,7 @@ def __call__( if name: self.name = name.lower() else: - self.name = self.env.temp_data.get('default_role', '') + self.name = self.env.current_document.default_role if not self.name: self.name = self.env.config.default_role if not self.name: diff --git a/tests/test_environment/test_environment.py b/tests/test_environment/test_environment.py index c50dc21c484..a32e6999146 100644 --- a/tests/test_environment/test_environment.py +++ b/tests/test_environment/test_environment.py @@ -185,13 +185,13 @@ def test_env_relfn2path(app): assert absfn == str(app.srcdir / 'logo.jpg') # omit docname (w/ current docname) - app.env.temp_data['docname'] = 'subdir/document' + app.env.current_document.docname = 'subdir/document' relfn, absfn = app.env.relfn2path('images/logo.jpg') assert Path(relfn) == Path('subdir/images/logo.jpg') assert absfn == str(app.srcdir / 'subdir' / 'images' / 'logo.jpg') # omit docname (w/o current docname) - app.env.temp_data.clear() + app.env.current_document.clear() with pytest.raises(KeyError, match=r"^'docname'$"): app.env.relfn2path('images/logo.jpg') diff --git a/tests/test_extensions/autodoc_util.py b/tests/test_extensions/autodoc_util.py index 7c4da07970e..3d08c739300 100644 --- a/tests/test_extensions/autodoc_util.py +++ b/tests/test_extensions/autodoc_util.py @@ -22,7 +22,8 @@ def do_autodoc( options: dict[str, Any] | None = None, ) -> StringList: options = {} if options is None else options.copy() - app.env.temp_data.setdefault('docname', 'index') # set dummy docname + if not app.env.current_document.docname: + app.env.current_document.docname = 'index' # set dummy docname doccls = app.registry.documenters[objtype] docoptions = process_documenter_options(doccls, app.config, options) state = Mock() diff --git a/tests/test_extensions/test_ext_autodoc.py b/tests/test_extensions/test_ext_autodoc.py index eb775aba081..89d8d037269 100644 --- a/tests/test_extensions/test_ext_autodoc.py +++ b/tests/test_extensions/test_ext_autodoc.py @@ -95,9 +95,10 @@ def verify(objtype, name, result): 'test_ext_autodoc.raises(exc) -> None', ('test_ext_autodoc', ['raises'], 'exc', 'None'), ) - directive.env.temp_data['autodoc:module'] = 'test_ext_autodoc' + directive.env.current_document.autodoc_module = 'test_ext_autodoc' verify('function', 'raises', ('test_ext_autodoc', ['raises'], None, None)) - del directive.env.temp_data['autodoc:module'] + directive.env.current_document.autodoc_module = '' + directive.env.ref_context['py:module'] = 'test_ext_autodoc' verify('function', 'raises', ('test_ext_autodoc', ['raises'], None, None)) verify('class', 'Base', ('test_ext_autodoc', ['Base'], None, None)) @@ -111,7 +112,7 @@ def verify(objtype, name, result): ) directive.env.ref_context['py:module'] = 'sphinx.testing.util' directive.env.ref_context['py:class'] = 'Foo' - directive.env.temp_data['autodoc:class'] = 'SphinxTestApp' + directive.env.current_document.autodoc_class = 'SphinxTestApp' verify( 'method', 'cleanup', @@ -526,7 +527,7 @@ def test_autodoc_exception(app): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_warnings(app): - app.env.temp_data['docname'] = 'dummy' + app.env.current_document.docname = 'dummy' # can't import module do_autodoc(app, 'module', 'unknown') @@ -1299,7 +1300,7 @@ def test_autodoc_module_member_order(app): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_module_scope(app): - app.env.temp_data['autodoc:module'] = 'target' + app.env.current_document.autodoc_module = 'target' actual = do_autodoc(app, 'attribute', 'Class.mdocattr') assert list(actual) == [ '', @@ -1314,8 +1315,8 @@ def test_autodoc_module_scope(app): @pytest.mark.sphinx('html', testroot='ext-autodoc') def test_autodoc_class_scope(app): - app.env.temp_data['autodoc:module'] = 'target' - app.env.temp_data['autodoc:class'] = 'Class' + app.env.current_document.autodoc_module = 'target' + app.env.current_document.autodoc_class = 'Class' actual = do_autodoc(app, 'attribute', 'mdocattr') assert list(actual) == [ '', diff --git a/tests/test_markup/test_markup.py b/tests/test_markup/test_markup.py index fa99fa352dd..62c4482ae55 100644 --- a/tests/test_markup/test_markup.py +++ b/tests/test_markup/test_markup.py @@ -38,7 +38,7 @@ def settings(app): settings = optparser.get_default_values() settings.smart_quotes = True settings.env = app.builder.env - settings.env.temp_data['docname'] = 'dummy' + settings.env.current_document.docname = 'dummy' settings.contentsname = 'dummy' domain_context = sphinx_domains(settings.env) domain_context.enable() diff --git a/tests/test_util/test_util_i18n.py b/tests/test_util/test_util_i18n.py index 4cb58fd46a7..c1dfad5c78b 100644 --- a/tests/test_util/test_util_i18n.py +++ b/tests/test_util/test_util_i18n.py @@ -116,7 +116,7 @@ def test_format_date_timezone(): @pytest.mark.sphinx('html', testroot='root') def test_get_filename_for_language(app): get_filename = i18n.get_image_filename_for_language - app.env.temp_data['docname'] = 'index' + app.env.current_document.docname = 'index' # language is en app.env.config.language = 'en' @@ -155,7 +155,7 @@ def test_get_filename_for_language(app): assert get_filename('foo.png', app.env) == '/en/foo.png' # docpath (for a document in the sub directory) - app.env.temp_data['docname'] = 'subdir/index' + app.env.current_document.docname = 'subdir/index' assert get_filename('foo.png', app.env) == '/subdir/en/foo.png'