diff --git a/CHANGES.rst b/CHANGES.rst index ab93335db79..093f6650524 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -149,6 +149,12 @@ Bugs fixed * #12995: Significantly improve performance when building the search index for Chinese languages. Patch by Adam Turner. +* #12767: :py:meth:`.Builder.write` is typed as ``final``, meaning that the + :event:`write-started` event may be relied upon by extensions. + A new :py:meth:`.Builder.write_documents` method has been added to + control how documents are written. + This is intended for builders that do not output a file for each document. + Patch by Adam Turner. Testing diff --git a/doc/_static/diagrams/sphinx_build_flow.dot b/doc/_static/diagrams/sphinx_build_flow.dot index 0e736f53733..59fbdd0d1e2 100644 --- a/doc/_static/diagrams/sphinx_build_flow.dot +++ b/doc/_static/diagrams/sphinx_build_flow.dot @@ -27,10 +27,6 @@ digraph build { "Builder.build_update":p1 -> "Builder.build"; "Builder.build" -> "Builder.read"; - "Builder.write" [ - shape=record - label = " Builder.write | Builder._write_serial | Builder._write_parallel" - ]; "Builder.build" -> "Builder.write"; "Builder.build" -> "Builder.finish"; @@ -39,8 +35,13 @@ digraph build { "Builder.write":p1 -> "Builder.prepare_writing"; "Builder.write":p1 -> "Builder.copy_assets"; - "Builder.write":p1 -> "Builder.write_doc"; + "Builder.write_documents" [ + shape=record + label = " Builder.write_documents | Builder._write_serial | Builder._write_parallel" + ]; + "Builder.write":p1 -> "Builder.write_documents"; + "Builder.write_documents":p1 -> "Builder.write_doc"; "Builder.write_doc" -> "Builder.get_relative_uri"; "Builder.get_relative_uri" -> "Builder.get_target_uri"; diff --git a/doc/_static/diagrams/sphinx_core_events_flow.dot b/doc/_static/diagrams/sphinx_core_events_flow.dot index 1499e6ba8fc..4ef46c1a67f 100644 --- a/doc/_static/diagrams/sphinx_core_events_flow.dot +++ b/doc/_static/diagrams/sphinx_core_events_flow.dot @@ -83,9 +83,9 @@ digraph events { // during write phase "write-started"[style=filled fillcolor="#D5FFFF" color=blue penwidth=2]; - "Builder.build":write -> "write-started"; "Builder.write" [label = "Builder.write()"] "Builder.build":write -> "Builder.write"; + "Builder.write" -> "write-started"; write_each_doc [shape="ellipse", label="for updated"]; "Builder.write" -> write_each_doc; "ReferenceResolver" [ @@ -120,6 +120,6 @@ digraph events { {rank=same; "env-get-outdated" "env-before-read-docs" "env-get-updated"}; {rank=same; "env-purge-doc" "source-read" "doctree-read", "merge_each_process"}; {rank=same; "env-updated" "env-check-consistency"}; - {rank=same; "env-merge-info" "write-started" "Builder.write"}; + {rank=same; "env-merge-info" "Builder.write"}; {rank=max; "build-finished"}; } diff --git a/doc/extdev/builderapi.rst b/doc/extdev/builderapi.rst index b5520d332c1..ccc0da8c5bd 100644 --- a/doc/extdev/builderapi.rst +++ b/doc/extdev/builderapi.rst @@ -39,20 +39,23 @@ Builder API .. automethod:: read .. automethod:: read_doc .. automethod:: write_doctree + .. automethod:: write - .. rubric:: Overridable Methods + .. rubric:: Abstract Methods These must be implemented in builder sub-classes: .. automethod:: get_outdated_docs - .. automethod:: prepare_writing .. automethod:: write_doc .. automethod:: get_target_uri + .. rubric:: Overridable Methods + These methods can be overridden in builder sub-classes: .. automethod:: init - .. automethod:: write + .. automethod:: write_documents + .. automethod:: prepare_writing .. automethod:: copy_assets .. automethod:: get_relative_uri .. automethod:: finish diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py index 4f1815b40da..68c62d9d125 100644 --- a/sphinx/builders/__init__.py +++ b/sphinx/builders/__init__.py @@ -41,7 +41,7 @@ from sphinx import roles # NoQA: F401 isort:skip if TYPE_CHECKING: - from collections.abc import Iterable, Sequence + from collections.abc import Iterable, Sequence, Set from docutils.nodes import Node @@ -664,6 +664,7 @@ def write_doctree( if _cache: self.env._write_doc_doctree_cache[docname] = doctree + @final def write( self, build_docnames: Iterable[str] | None, @@ -685,11 +686,12 @@ def write( logger.debug(__('docnames to write: %s'), ', '.join(sorted(docnames))) # add all toctree-containing files that may have changed - for docname in list(docnames): + extra = {self.config.root_doc} + for docname in docnames: for tocdocname in self.env.files_to_rebuild.get(docname, set()): if tocdocname in self.env.found_docs: - docnames.add(tocdocname) - docnames.add(self.config.root_doc) + extra.add(tocdocname) + docnames |= extra # sort to ensure deterministic toctree generation self.env.toctree_includes = dict(sorted(self.env.toctree_includes.items())) @@ -700,12 +702,21 @@ def write( with progress_message(__('copying assets'), nonl=False): self.copy_assets() + self.write_documents(docnames) + + def write_documents(self, docnames: Set[str]) -> None: + """Write all documents in *docnames*. + + This method can be overridden if a builder does not create + output files for each document. + """ + sorted_docnames = sorted(docnames) if self.parallel_ok: # number of subprocesses is parallel-1 because the main process # is busy loading doctrees and doing write_doc_serialized() - self._write_parallel(sorted(docnames), nproc=self.app.parallel - 1) + self._write_parallel(sorted_docnames, nproc=self.app.parallel - 1) else: - self._write_serial(sorted(docnames)) + self._write_serial(sorted_docnames) def _write_serial(self, docnames: Sequence[str]) -> None: with ( @@ -769,9 +780,9 @@ def on_chunk_done(args: list[tuple[str, nodes.document]], result: None) -> None: tasks.join() logger.info('') - def prepare_writing(self, docnames: set[str]) -> None: + def prepare_writing(self, docnames: Set[str]) -> None: """A place where you can add logic before :meth:`write_doc` is run""" - raise NotImplementedError + pass def copy_assets(self) -> None: """Where assets (images, static files, etc) are copied before writing""" diff --git a/sphinx/builders/changes.py b/sphinx/builders/changes.py index ed53c74e201..cd40faf3ab7 100644 --- a/sphinx/builders/changes.py +++ b/sphinx/builders/changes.py @@ -4,7 +4,7 @@ import html from os import path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from sphinx import package_dir from sphinx.builders import Builder @@ -16,6 +16,8 @@ from sphinx.util.osutil import ensuredir, os_path if TYPE_CHECKING: + from collections.abc import Set + from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -46,7 +48,7 @@ def get_outdated_docs(self) -> str: 'versionremoved': 'removed', } - def write(self, *ignored: Any) -> None: + def write_documents(self, _docnames: Set[str]) -> None: version = self.config.version domain = self.env.domains.changeset_domain libchanges: dict[str, list[tuple[str, str, int]]] = {} diff --git a/sphinx/builders/dummy.py b/sphinx/builders/dummy.py index 05b7e568791..f8a4c298d30 100644 --- a/sphinx/builders/dummy.py +++ b/sphinx/builders/dummy.py @@ -29,9 +29,6 @@ def get_outdated_docs(self) -> set[str]: def get_target_uri(self, docname: str, typ: str | None = None) -> str: return '' - def prepare_writing(self, docnames: set[str]) -> None: - pass - def write_doc(self, docname: str, doctree: nodes.document) -> None: pass diff --git a/sphinx/builders/epub3.py b/sphinx/builders/epub3.py index 1699ae2f67f..cbd50bd229e 100644 --- a/sphinx/builders/epub3.py +++ b/sphinx/builders/epub3.py @@ -21,6 +21,8 @@ from sphinx.util.osutil import make_filename if TYPE_CHECKING: + from collections.abc import Set + from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -120,7 +122,7 @@ def content_metadata(self) -> dict[str, Any]: metadata['epub_version'] = self.config.epub_version return metadata - def prepare_writing(self, docnames: set[str]) -> None: + def prepare_writing(self, docnames: Set[str]) -> None: super().prepare_writing(docnames) writing_mode = self.config.epub_writing_mode diff --git a/sphinx/builders/gettext.py b/sphinx/builders/gettext.py index 3281026b086..5274bc77191 100644 --- a/sphinx/builders/gettext.py +++ b/sphinx/builders/gettext.py @@ -158,9 +158,6 @@ def get_target_uri(self, docname: str, typ: str | None = None) -> str: def get_outdated_docs(self) -> set[str]: return self.env.found_docs - def prepare_writing(self, docnames: set[str]) -> None: - return - def compile_catalogs(self, catalogs: set[CatalogInfo], message: str) -> None: return diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 516a885c38c..a38d12b60a8 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -64,7 +64,7 @@ from sphinx.writers.html5 import HTML5Translator if TYPE_CHECKING: - from collections.abc import Iterable, Iterator + from collections.abc import Iterator, Set from typing import TypeAlias from docutils.nodes import Node @@ -420,7 +420,7 @@ def render_partial(self, node: Node | None) -> dict[str, str]: self._publisher.publish() return self._publisher.writer.parts - def prepare_writing(self, docnames: set[str]) -> None: + def prepare_writing(self, docnames: Set[str]) -> None: # create the search indexer self.indexer = None if self.search: @@ -965,9 +965,9 @@ def post_process_images(self, doctree: Node) -> None: node.replace_self(reference) reference.append(node) - def load_indexer(self, docnames: Iterable[str]) -> None: + def load_indexer(self, docnames: Set[str]) -> None: assert self.indexer is not None - keep = set(self.env.all_docs) - set(docnames) + keep = set(self.env.all_docs).difference(docnames) try: searchindexfn = path.join(self.outdir, self.searchindex_filename) if self.indexer_dumps_unicode: diff --git a/sphinx/builders/latex/__init__.py b/sphinx/builders/latex/__init__.py index b37dd7153a4..c51fa7607a2 100644 --- a/sphinx/builders/latex/__init__.py +++ b/sphinx/builders/latex/__init__.py @@ -38,7 +38,7 @@ from docutils import nodes # isort:skip if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Set from docutils.nodes import Node @@ -291,13 +291,17 @@ def write_stylesheet(self) -> None: ) f.write(highlighter.get_stylesheet()) + def prepare_writing(self, docnames: Set[str]) -> None: + self.init_document_data() + self.write_stylesheet() + def copy_assets(self) -> None: self.copy_support_files() if self.config.latex_additional_files: self.copy_latex_additional_files() - def write(self, *ignored: Any) -> None: + def write_documents(self, _docnames: Set[str]) -> None: docwriter = LaTeXWriter(self) with warnings.catch_warnings(): warnings.filterwarnings('ignore', category=DeprecationWarning) @@ -309,10 +313,6 @@ def write(self, *ignored: Any) -> None: read_config_files=True, ).get_default_values() - self.init_document_data() - self.write_stylesheet() - self.copy_assets() - for entry in self.document_data: docname, targetname, title, author, themename = entry[:5] theme = self.themes.get(themename) diff --git a/sphinx/builders/manpage.py b/sphinx/builders/manpage.py index 557bbddd5bf..2e24486d174 100644 --- a/sphinx/builders/manpage.py +++ b/sphinx/builders/manpage.py @@ -20,6 +20,8 @@ from sphinx.writers.manpage import ManualPageTranslator, ManualPageWriter if TYPE_CHECKING: + from collections.abc import Set + from sphinx.application import Sphinx from sphinx.config import Config from sphinx.util.typing import ExtensionMetadata @@ -55,7 +57,7 @@ def get_target_uri(self, docname: str, typ: str | None = None) -> str: return '' @progress_message(__('writing')) - def write(self, *ignored: Any) -> None: + def write_documents(self, _docnames: Set[str]) -> None: docwriter = ManualPageWriter(self) with warnings.catch_warnings(): warnings.filterwarnings('ignore', category=DeprecationWarning) diff --git a/sphinx/builders/singlehtml.py b/sphinx/builders/singlehtml.py index 73c506faea1..6715142ba91 100644 --- a/sphinx/builders/singlehtml.py +++ b/sphinx/builders/singlehtml.py @@ -16,6 +16,8 @@ from sphinx.util.nodes import inline_all_toctrees if TYPE_CHECKING: + from collections.abc import Set + from docutils.nodes import Node from sphinx.application import Sphinx @@ -160,11 +162,8 @@ def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, A 'display_toc': display_toc, } - def write(self, *ignored: Any) -> None: - docnames = self.env.all_docs - - with progress_message(__('preparing documents')): - self.prepare_writing(docnames) # type: ignore[arg-type] + def write_documents(self, _docnames: Set[str]) -> None: + self.prepare_writing(self.env.all_docs.keys()) with progress_message(__('assembling single document'), nonl=False): doctree = self.assemble_doctree() diff --git a/sphinx/builders/texinfo.py b/sphinx/builders/texinfo.py index c085d8fde52..2d428bb736b 100644 --- a/sphinx/builders/texinfo.py +++ b/sphinx/builders/texinfo.py @@ -25,7 +25,7 @@ from sphinx.writers.texinfo import TexinfoTranslator, TexinfoWriter if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Set from docutils.nodes import Node @@ -71,7 +71,7 @@ def get_relative_uri(self, from_: str, to: str, typ: str | None = None) -> str: # ignore source path return self.get_target_uri(to, typ) - def init_document_data(self) -> None: + def prepare_writing(self, _docnames: Set[str]) -> None: preliminary_document_data = [list(x) for x in self.config.texinfo_documents] if not preliminary_document_data: logger.warning( @@ -98,9 +98,7 @@ def init_document_data(self) -> None: docname = docname.removesuffix(SEP + 'index') self.titles.append((docname, entry[2])) - def write(self, *ignored: Any) -> None: - self.init_document_data() - self.copy_assets() + def write_documents(self, _docnames: Set[str]) -> None: for entry in self.document_data: docname, targetname, title, author = entry[:4] targetname += '.texi' diff --git a/sphinx/builders/text.py b/sphinx/builders/text.py index c3fe666c4fa..243e790124f 100644 --- a/sphinx/builders/text.py +++ b/sphinx/builders/text.py @@ -18,7 +18,7 @@ from sphinx.writers.text import TextTranslator, TextWriter if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Set from docutils import nodes @@ -64,7 +64,7 @@ def get_outdated_docs(self) -> Iterator[str]: def get_target_uri(self, docname: str, typ: str | None = None) -> str: return '' - def prepare_writing(self, docnames: set[str]) -> None: + def prepare_writing(self, docnames: Set[str]) -> None: self.writer = TextWriter(self) def write_doc(self, docname: str, doctree: nodes.document) -> None: diff --git a/sphinx/builders/xml.py b/sphinx/builders/xml.py index d675fc4accf..dbb78772a00 100644 --- a/sphinx/builders/xml.py +++ b/sphinx/builders/xml.py @@ -20,7 +20,7 @@ from sphinx.writers.xml import PseudoXMLWriter, XMLWriter if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Set from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata @@ -68,7 +68,7 @@ def get_outdated_docs(self) -> Iterator[str]: def get_target_uri(self, docname: str, typ: str | None = None) -> str: return docname - def prepare_writing(self, docnames: set[str]) -> None: + def prepare_writing(self, docnames: Set[str]) -> None: self.writer = self._writer_class(self) def write_doc(self, docname: str, doctree: nodes.document) -> None: diff --git a/sphinx/ext/coverage.py b/sphinx/ext/coverage.py index b287f6c8a85..c9b997642d3 100644 --- a/sphinx/ext/coverage.py +++ b/sphinx/ext/coverage.py @@ -192,7 +192,7 @@ def init(self) -> None: def get_outdated_docs(self) -> str: return 'coverage overview' - def write(self, *ignored: Any) -> None: + def write_documents(self, _docnames: Set[str]) -> None: self.py_undoc: dict[str, dict[str, Any]] = {} self.py_undocumented: dict[str, Set[str]] = {} self.py_documented: dict[str, Set[str]] = {} diff --git a/sphinx/ext/doctest.py b/sphinx/ext/doctest.py index ba451208a5e..633d438979c 100644 --- a/sphinx/ext/doctest.py +++ b/sphinx/ext/doctest.py @@ -27,7 +27,7 @@ from sphinx.util.osutil import relpath if TYPE_CHECKING: - from collections.abc import Callable, Iterable, Sequence + from collections.abc import Callable, Set from docutils.nodes import Element, Node, TextElement @@ -355,13 +355,9 @@ def s(v: int) -> str: if self.total_failures or self.setup_failures or self.cleanup_failures: self.app.statuscode = 1 - def write(self, build_docnames: Iterable[str] | None, updated_docnames: Sequence[str], - method: str = 'update') -> None: - if build_docnames is None: - build_docnames = sorted(self.env.all_docs) - + def write_documents(self, docnames: Set[str]) -> None: logger.info(bold('running tests...')) - for docname in build_docnames: + for docname in sorted(docnames): # no need to resolve the doctree doctree = self.env.get_doctree(docname) self.test_doc(docname, doctree)