This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 68d603c4ee5 Speed up documentation build and spellcheck in CI (#73477) 
(#73550)
68d603c4ee5 is described below

commit 68d603c4ee59745bf39b3726aca6c37b58e45dba
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Sep 22 17:34:16 2026 +0200

    Speed up documentation build and spellcheck in CI (#73477) (#73550)
    
    Run Sphinx in-process inside each docs build worker so the fixed per-package
    startup (autoapi's astroid parsing of the airflow modules, provider.yaml
    parsing, the airflow import, inventory loading) is paid once per worker
    instead of once per package. A full 115-package build drops from 645 s to
    214 s locally with 4 workers (41.6 to 14.2 CPU-minutes); a small provider
    now takes about 2 s instead of 17 s.
    
    Make the spelling builder write an inventory so a --spellcheck-only run can
    resolve references to pages added in the same change, and retry only the
    packages that failed instead of rebuilding everything with html and spelling
    again. That rebuild-all pass is what turned a 28-minute spellcheck job into 
a
    93-minute one.
    
    Also schedule the heaviest packages first with one-at-a-time dispatch, parse
    each provider.yaml once with a compiled validator and the C YAML loader, and
    skip the runner disk cleanup for the docs job (93 GB were free before it).
    
    Runners are not uniform, so the docs job now passes a 40 GB threshold to 
the prepare action instead of skipping the cleanup unconditionally. Also 
document why a spelling build that fails on misspellings still publishes its 
inventory: under -W Sphinx counts warnings and fails at the end rather than 
aborting.
    
    (cherry picked from commit b8be9e54beef9174c95ca37fa5c4d1e0764ab0cb)
    
    Co-authored-by: Kaxil Naik <[email protected]>
---
 .../actions/prepare_breeze_and_image/action.yml    |  18 +-
 .github/workflows/ci-image-checks.yml              |   3 +
 devel-common/src/docs/build_docs.py                |  99 +++++------
 devel-common/src/docs/provider_conf.py             |   7 +-
 .../src/sphinx_exts/airflow_intersphinx.py         |  67 +++++--
 .../src/sphinx_exts/docs_build/docs_builder.py     | 197 +++++++++++++++++----
 .../src/sphinx_exts/provider_yaml_utils.py         |  42 ++++-
 .../docs_build/test_build_docs_scheduling.py       |  41 +++++
 .../sphinx_exts/docs_build/test_docs_builder.py    | 146 +++++++++++++++
 .../unit/sphinx_exts/test_airflow_intersphinx.py   | 113 ++++++++++++
 10 files changed, 625 insertions(+), 108 deletions(-)

diff --git a/.github/actions/prepare_breeze_and_image/action.yml 
b/.github/actions/prepare_breeze_and_image/action.yml
index 6e6c4efd205..1815b2a743c 100644
--- a/.github/actions/prepare_breeze_and_image/action.yml
+++ b/.github/actions/prepare_breeze_and_image/action.yml
@@ -34,6 +34,14 @@ inputs:
   make-mnt-writeable-and-cleanup:
     description: 'Whether to cleanup /mnt'
     required: true
+  min-free-space-gb:
+    description: >
+      Skip removing the runner's unused pre-installed tooling (close to three 
minutes) when the root
+      filesystem already has at least this many GB free. Empty means always 
remove it. Runners are
+      not uniform, so a job whose footprint is only the CI image (docs build) 
passes a threshold
+      rather than skipping unconditionally.
+    required: false
+    default: ""
 outputs:
   host-python-version:
     description: Python version used in host
@@ -47,7 +55,15 @@ runs:
       if: inputs.make-mnt-writeable-and-cleanup == 'true'
     - name: "Free up disk space"
       shell: bash
-      run: ./scripts/tools/free_up_disk_space.sh
+      env:
+        MIN_FREE_SPACE_GB: ${{ inputs.min-free-space-gb }}
+      run: |
+        FREE_GB=$(df --output=avail --block-size=1G / | tail -1 | tr -d ' ')
+        if [[ -n "${MIN_FREE_SPACE_GB}" && "${FREE_GB}" -ge 
"${MIN_FREE_SPACE_GB}" ]]; then
+          echo "${FREE_GB} GB free on / (>= ${MIN_FREE_SPACE_GB} GB), skipping 
the disk cleanup"
+        else
+          ./scripts/tools/free_up_disk_space.sh
+        fi
     - name: "Install Breeze"
       uses: ./.github/actions/breeze
       id: breeze
diff --git a/.github/workflows/ci-image-checks.yml 
b/.github/workflows/ci-image-checks.yml
index aa5b75d52ed..87afc3c8a1f 100644
--- a/.github/workflows/ci-image-checks.yml
+++ b/.github/workflows/ci-image-checks.yml
@@ -193,6 +193,9 @@ jobs:
           python: "${{ inputs.default-python-version }}"
           use-uv: ${{ inputs.use-uv }}
           make-mnt-writeable-and-cleanup: true
+          # The docs build needs ~25 GB (image tar, loaded image, built docs); 
runners usually
+          # start with ~90 GB free, in which case the 3-minute cleanup is 
skipped.
+          min-free-space-gb: 40
       - name: "Restore docs inventory cache"
         uses: 
apache/infrastructure-actions/stash/restore@ec1b354a0455b41001d77473236d02a0ee0adba4
         with:
diff --git a/devel-common/src/docs/build_docs.py 
b/devel-common/src/docs/build_docs.py
index 860df0115c7..54ddeb3102b 100755
--- a/devel-common/src/docs/build_docs.py
+++ b/devel-common/src/docs/build_docs.py
@@ -38,7 +38,7 @@ from rich.console import Console
 from tabulate import tabulate
 
 from sphinx_exts.docs_build import dev_index_generator
-from sphinx_exts.docs_build.code_utils import CONSOLE_WIDTH, GENERATED_PATH
+from sphinx_exts.docs_build.code_utils import AIRFLOW_CONTENT_ROOT_PATH, 
CONSOLE_WIDTH, GENERATED_PATH
 from sphinx_exts.docs_build.docs_builder import (
     AirflowDocsBuilder,
     get_available_packages,
@@ -328,6 +328,34 @@ def print_build_output(result: BuildDocsResult):
         console.print(f"[bright_blue]{result.package_name:60}: " + "#" * 80)
 
 
+_API_SOURCE_ROOTS = {
+    "apache-airflow": AIRFLOW_CONTENT_ROOT_PATH / "airflow-core" / "src",
+    "task-sdk": AIRFLOW_CONTENT_ROOT_PATH / "task-sdk" / "src",
+    "apache-airflow-ctl": AIRFLOW_CONTENT_ROOT_PATH / "airflow-ctl" / "src",
+}
+
+
+def _estimated_build_weight(package_name: str) -> int:
+    """Number of Python files autoapi will parse: the rough cost of building 
one package."""
+    builder = AirflowDocsBuilder(package_name=package_name)
+    source_root = (
+        builder.provider_path / "src" if builder.is_provider else 
_API_SOURCE_ROOTS.get(package_name)
+    )
+    if source_root is None or not source_root.exists():
+        return 0
+    return sum(1 for _ in source_root.rglob("*.py"))
+
+
+def sort_heaviest_first(packages: list[str]) -> list[str]:
+    """
+    Order packages so the long builds start first.
+
+    With a handful of workers, starting google/amazon/apache-airflow last 
would leave the other workers
+    idle at the end; starting them first lets the many small packages fill in 
around them.
+    """
+    return sorted(packages, key=lambda package_name: 
(-_estimated_build_weight(package_name), package_name))
+
+
 def run_docs_build_in_parallel(
     all_build_errors: dict[str, list[DocBuildError]],
     packages_to_build: list[str],
@@ -337,7 +365,7 @@ def run_docs_build_in_parallel(
     """Runs documentation building in parallel."""
     doc_build_specifications: list[BuildSpecification] = []
     with with_group("Scheduling documentation to build"):
-        for package_name in packages_to_build:
+        for package_name in sort_heaviest_first(packages_to_build):
             console.print(f"[bright_blue]{package_name:60}:[/] Scheduling 
documentation to build")
             doc_build_specifications.append(
                 BuildSpecification(
@@ -348,7 +376,11 @@ def run_docs_build_in_parallel(
             )
     with with_group("Running docs building"):
         console.print()
-        result_list = pool.map(perform_docs_build_for_single_package, 
doc_build_specifications)
+        # chunksize=1 hands packages out one at a time, so a worker that 
finishes early picks up the
+        # next package instead of the fixed chunk pool.map would have 
pre-assigned to it.
+        result_list = list(
+            pool.imap_unordered(perform_docs_build_for_single_package, 
doc_build_specifications, chunksize=1)
+        )
     for result in result_list:
         if result.errors:
             all_build_errors[result.package_name].extend(result.errors)
@@ -377,14 +409,18 @@ def run_spell_check_in_parallel(
     """Runs spell check in parallel."""
     spell_check_specifications: list[BuildSpecification] = []
     with with_group("Scheduling spell checking of documentation"):
-        for package_name in packages_to_build:
+        for package_name in sort_heaviest_first(packages_to_build):
             console.print(f"[bright_blue]{package_name:60}:[/] Scheduling 
spellchecking")
             spell_check_specifications.append(
                 BuildSpecification(package_name=package_name, 
is_autobuild=False, verbose=verbose)
             )
     with with_group("Running spell checking of documentation"):
         console.print()
-        result_list = pool.map(perform_spell_check_for_single_package, 
spell_check_specifications)
+        result_list = list(
+            pool.imap_unordered(
+                perform_spell_check_for_single_package, 
spell_check_specifications, chunksize=1
+            )
+        )
     for result in result_list:
         if result.spelling_errors:
             
all_spelling_errors[result.package_name].extend(result.spelling_errors)
@@ -693,52 +729,19 @@ def build_docs(
         all_spelling_errors.update(package_spelling_errors)
 
     if not one_pass_only:
-        # Build documentation for some packages again if it can help them.
-        package_build_errors = retry_building_docs_if_needed(
-            all_build_errors=all_build_errors,
-            all_spelling_errors=all_spelling_errors,
-            autobuild=autobuild,
-            docs_only=docs_only,
-            jobs=jobs,
-            verbose=verbose,
-            package_build_errors=package_build_errors,
-            originally_built_packages=packages_to_build,
-            # If spellchecking fails, we need to rebuild all packages first in 
case some references
-            # are broken between packages
-            rebuild_all_packages=spellcheck_only,
-        )
-
-        # And try again in case one change spans across three-level 
dependencies.
-        package_build_errors = retry_building_docs_if_needed(
-            all_build_errors=all_build_errors,
-            all_spelling_errors=all_spelling_errors,
-            autobuild=autobuild,
-            docs_only=docs_only,
-            jobs=jobs,
-            verbose=verbose,
-            package_build_errors=package_build_errors,
-            originally_built_packages=packages_to_build,
-            # In the 3rd pass we only rebuild packages that failed in the 2nd 
pass
-            # no matter if we do spellcheck-only build
-            rebuild_all_packages=False,
-        )
-
-        if spellcheck_only:
-            # And in case of spellcheck-only, we add a 4th pass to account for 
A->B-C case
-            # For spellcheck-only build, the first pass does not solve any of 
the dependency
-            # Issues, they only start getting solved and the 2nd pass so we 
might need to do one more pass
+        # Packages that failed on a cross-reference to a package built later 
in the same pass are
+        # built again now that the inventory exists (spelling builds write one 
too, see
+        # airflow_intersphinx). The second retry covers a change spanning A -> 
B -> C dependencies.
+        for _ in range(2):
             package_build_errors = retry_building_docs_if_needed(
                 all_build_errors=all_build_errors,
                 all_spelling_errors=all_spelling_errors,
                 autobuild=autobuild,
                 docs_only=docs_only,
+                spellcheck_only=spellcheck_only,
                 jobs=jobs,
                 verbose=verbose,
                 package_build_errors=package_build_errors,
-                originally_built_packages=packages_to_build,
-                # In the 4th pass we only rebuild packages that failed in the 
3rd pass
-                # no matter if we do spellcheck-only build
-                rebuild_all_packages=False,
             )
 
     dev_index_generator.generate_index(f"{GENERATED_PATH}/_build/index.html")
@@ -758,11 +761,10 @@ def retry_building_docs_if_needed(
     all_spelling_errors: dict[str, list[SpellingError]],
     autobuild: bool,
     docs_only: bool,
+    spellcheck_only: bool,
     jobs: int,
     verbose: bool,
     package_build_errors: dict[str, list[DocBuildError]],
-    originally_built_packages: list[str],
-    rebuild_all_packages: bool,
 ) -> dict[str, list[DocBuildError]]:
     to_retry_packages = [
         package_name
@@ -773,11 +775,6 @@ def retry_building_docs_if_needed(
         console.print("[green]No packages to retry. No more passes are 
needed.[/]")
         return package_build_errors
     console.print("[warning] Some packages failed to build due to 
dependencies. We need another pass.[/]")
-    # if we are rebuilding all packages, we need to retry all packages
-    # even if there is one package to rebuild only
-    if rebuild_all_packages:
-        console.print("[warning]Rebuilding all originally built package as 
this is the first build pass:[/]")
-        to_retry_packages = originally_built_packages
     console.print(f"[bright_blue]Packages to rebuild: {to_retry_packages}[/]")
     for package_name in to_retry_packages:
         if package_name in all_build_errors:
@@ -788,7 +785,7 @@ def retry_building_docs_if_needed(
         packages_to_build=to_retry_packages,
         is_autobuild=autobuild,
         docs_only=docs_only,
-        spellcheck_only=False,
+        spellcheck_only=spellcheck_only,
         jobs=jobs,
         verbose=verbose,
     )
diff --git a/devel-common/src/docs/provider_conf.py 
b/devel-common/src/docs/provider_conf.py
index 96d5e2f93cc..4df107b7d41 100644
--- a/devel-common/src/docs/provider_conf.py
+++ b/devel-common/src/docs/provider_conf.py
@@ -293,7 +293,12 @@ autoapi_dirs = [BASE_PROVIDER_SRC_PATH.as_posix()]
 autoapi_ignore = BASIC_AUTOAPI_IGNORE_PATTERNS
 
 autoapi_log = logging.getLogger("sphinx.autoapi.mappers.base")
-autoapi_log.addFilter(filter_autoapi_ignore_entries)
+# One process builds several packages in a row and re-imports this module each 
time, so guard
+# against stacking the same filter on the (process-wide) logger.
+if not any(
+    getattr(f, "__name__", None) == filter_autoapi_ignore_entries.__name__ for 
f in autoapi_log.filters
+):
+    autoapi_log.addFilter(filter_autoapi_ignore_entries)
 
 autoapi_python_use_implicit_namespaces = True
 
diff --git a/devel-common/src/sphinx_exts/airflow_intersphinx.py 
b/devel-common/src/sphinx_exts/airflow_intersphinx.py
index 41f8d36659b..a5b83bfcff4 100644
--- a/devel-common/src/sphinx_exts/airflow_intersphinx.py
+++ b/devel-common/src/sphinx_exts/airflow_intersphinx.py
@@ -22,12 +22,61 @@ from pathlib import Path
 from typing import TYPE_CHECKING, Any
 
 from provider_yaml_utils import load_package_data
+from sphinx.util.inventory import InventoryFile
 
 if TYPE_CHECKING:
     from sphinx.application import Sphinx
 
 AIRFLOW_ROOT_PATH = Path(os.path.abspath(__file__)).parents[3]
 GENERATED_PATH = AIRFLOW_ROOT_PATH / "generated"
+INVENTORY_FILENAME = "objects.inv"
+
+
+def _inventory_for(package_name: str, versioned: bool) -> Path:
+    """
+    Prefer an inventory written earlier in this run, fall back to the one 
fetched from the published docs.
+
+    The html builder writes its inventory at the root of the build directory; 
a spelling-only build
+    writes one into its own output directory (see 
``_dump_inventory_after_spelling_build``, the
+    directory name mirrors ``AirflowDocsBuilder.log_spelling_output_dir``).
+    """
+    build_dir = GENERATED_PATH / "_build" / "docs" / package_name / ("stable" 
if versioned else "")
+    built_this_run = (
+        build_dir / INVENTORY_FILENAME,
+        build_dir / f"output-spelling-results-{package_name}" / 
INVENTORY_FILENAME,
+    )
+    downloaded = GENERATED_PATH / "_inventory_cache" / package_name / 
INVENTORY_FILENAME
+    return next((inventory for inventory in built_this_run if 
inventory.exists()), downloaded)
+
+
+class _HtmlTargetUris:
+    """Gives inventory entries the URIs the html build would, so the file is 
an ordinary inventory."""
+
+    @staticmethod
+    def get_target_uri(docname: str, typ: str | None = None) -> str:
+        return f"{docname}.html"
+
+
+def _dump_inventory_after_spelling_build(app: Sphinx, exception: Exception | 
None) -> None:
+    """
+    Write ``objects.inv`` after a successful spelling build.
+
+    Cross-references are resolved by every builder, so a ``--spellcheck-only`` 
run of package B fails
+    on a label that package A added in the same change unless A's fresh 
inventory is available. Only
+    the html builder writes inventories, which used to force the spellcheck 
run to rebuild every
+    package a second time with html. Writing the inventory here lets a 
spellcheck-only run resolve
+    references the same way a docs build does.
+
+    "Successful" means the build ran to completion, not that it passed: under 
``-W`` Sphinx (8.1 and
+    9.x) counts warnings and sets a non-zero exit status at the end instead of 
aborting, so a package
+    with a genuine misspelling or an unresolved reference still reaches this 
hook with
+    ``exception=None`` and still publishes its complete inventory for the 
packages that depend on it.
+    Only a build that crashed part-way (``exception`` set) is skipped, because 
its environment may
+    not hold every document.
+    """
+    if exception is not None or app.builder.name != "spelling":
+        return
+    InventoryFile.dump(os.path.join(app.outdir, INVENTORY_FILENAME), app.env, 
_HtmlTargetUris())  # type: ignore[arg-type]
 
 
 def _create_init_py(app, config):
@@ -51,39 +100,32 @@ def _generate_provider_intersphinx_mapping() -> dict[str, 
tuple[str, tuple[str,
             continue
 
         provider_base_url = f"/docs/{package_name}/{current_version}/"
-        doc_inventory = GENERATED_PATH / "_build" / "docs" / package_name / 
current_version / "objects.inv"
-        cache_inventory = GENERATED_PATH / "_inventory_cache" / package_name / 
"objects.inv"
+        inventory = _inventory_for(package_name, versioned=True)
 
         # Skip adding the mapping if the path does not exist
-        if not os.path.exists(doc_inventory) and not 
os.path.exists(cache_inventory):
+        if not inventory.exists():
             continue
 
         airflow_mapping[package_name] = (
             # base URI
             provider_base_url,
-            (doc_inventory.as_posix() if doc_inventory.exists() else 
cache_inventory.as_posix(),),
+            (inventory.as_posix(),),
         )
     for pkg_name in ["apache-airflow", "helm-chart", "task-sdk"]:
         if os.environ.get("AIRFLOW_PACKAGE_NAME") == pkg_name:
             continue
-        doc_inventory = GENERATED_PATH / "_build" / "docs" / pkg_name / 
current_version / "objects.inv"
-        cache_inventory = GENERATED_PATH / "_inventory_cache" / pkg_name / 
"objects.inv"
-
         airflow_mapping[pkg_name] = (
             # base URI
             f"/docs/{pkg_name}/stable/",
-            (doc_inventory.as_posix() if doc_inventory.exists() else 
cache_inventory.as_posix(),),
+            (_inventory_for(pkg_name, versioned=True).as_posix(),),
         )
     for pkg_name in ["apache-airflow-providers", "docker-stack"]:
         if os.environ.get("AIRFLOW_PACKAGE_NAME") == pkg_name:
             continue
-        doc_inventory = GENERATED_PATH / "_build" / "docs" / pkg_name / 
"objects.inv"
-        cache_inventory = GENERATED_PATH / "_inventory_cache" / pkg_name / 
"objects.inv"
-
         airflow_mapping[pkg_name] = (
             # base URI
             f"/docs/{pkg_name}/",
-            (doc_inventory.as_posix() if doc_inventory.exists() else 
cache_inventory.as_posix(),),
+            (_inventory_for(pkg_name, versioned=False).as_posix(),),
         )
     return airflow_mapping
 
@@ -91,6 +133,7 @@ def _generate_provider_intersphinx_mapping() -> dict[str, 
tuple[str, tuple[str,
 def setup(app: Sphinx):
     """Sets the plugin up"""
     app.connect("config-inited", _create_init_py)
+    app.connect("build-finished", _dump_inventory_after_spelling_build)
 
     return {"version": "builtin", "parallel_read_safe": True, 
"parallel_write_safe": True}
 
diff --git a/devel-common/src/sphinx_exts/docs_build/docs_builder.py 
b/devel-common/src/sphinx_exts/docs_build/docs_builder.py
index 147215a7353..a7df0ad6a10 100644
--- a/devel-common/src/sphinx_exts/docs_build/docs_builder.py
+++ b/devel-common/src/sphinx_exts/docs_build/docs_builder.py
@@ -16,21 +16,27 @@
 # under the License.
 from __future__ import annotations
 
+import contextlib
 import os
 import re
 import shlex
 import shutil
+import signal
 import sys
+import threading
+from collections.abc import Iterator
 from pathlib import Path
 from subprocess import run
 
 from rich.console import Console
+from sphinx.cmd.build import build_main
 
 from sphinx_exts.docs_build.code_utils import (
     AIRFLOW_CONTENT_ROOT_PATH,
     ALL_PROVIDER_YAMLS,
     ALL_PROVIDER_YAMLS_WITH_SUSPENDED,
     CONSOLE_WIDTH,
+    DOCS_SOURCES_PATH,
     GENERATED_PATH,
     PROCESS_TIMEOUT,
 )
@@ -39,6 +45,121 @@ from sphinx_exts.docs_build.spelling_checks import 
SpellingError, parse_spelling
 
 console = Console(force_terminal=True, color_system="standard", 
width=CONSOLE_WIDTH)
 
+# Sphinx is run in the current process (one worker builds many packages in a 
row) so that the fixed
+# start-up cost of a build is paid once per worker instead of once per 
package. For a small provider
+# that fixed cost is ~20s out of ~22s: importing airflow, parsing and 
validating every provider.yaml,
+# and - the largest part - autoapi's astroid parsing of the airflow modules 
every provider imports.
+# The astroid parse cache in particular stays warm across the packages a 
worker builds.
+
+
+def _forget_sphinx_conf_modules() -> None:
+    """
+    Drop the shared Sphinx configuration modules so the next conf.py 
re-executes them from scratch.
+
+    The per-package ``conf.py`` files do ``from docs.provider_conf import *`` 
(or import
+    ``docs.utils.conf_constants``) and then mutate the lists they get - 
``extensions.append(...)``,
+    ``autoapi_ignore.extend(...)``. Reusing the cached module would leak one 
package's additions into
+    the next build, so everything under ``devel-common/src/docs`` except the 
build script is forgotten.
+    """
+    docs_sources_prefix = DOCS_SOURCES_PATH.as_posix()
+    for name, module in list(sys.modules.items()):
+        if name == "docs.build_docs" or not name.startswith("docs."):
+            continue
+        module_file = getattr(module, "__file__", None) or ""
+        if module_file.startswith(docs_sources_prefix):
+            del sys.modules[name]
+
+
+class _RedirectableStream:
+    """
+    Stand-in for ``sys.stdout`` / ``sys.stderr`` that lives as long as the 
worker process.
+
+    Libraries keep a reference to whatever stream is current when they first 
need one (docutils'
+    ``Reporter`` in sphinx-argparse's nested parser is one example). 
Redirecting straight to a
+    per-package log file would leave such references pointing at a closed file 
once that package is
+    done and crash the next build with "I/O operation on closed file". This 
object never closes; it
+    only changes where it writes: the current package's log file during a 
build, the worker's real
+    stream otherwise.
+    """
+
+    def __init__(self, fallback) -> None:
+        self._fallback = fallback
+        self.target = None
+
+    def _stream(self):
+        return self.target if self.target is not None else self._fallback
+
+    def write(self, data: str) -> int:
+        return self._stream().write(data)
+
+    def flush(self) -> None:
+        self._stream().flush()
+
+    def isatty(self) -> bool:
+        return False
+
+    @property
+    def encoding(self) -> str:
+        return getattr(self._stream(), "encoding", None) or "utf-8"
+
+
+_STDOUT_PROXY = _RedirectableStream(sys.stdout)
+_STDERR_PROXY = _RedirectableStream(sys.stderr)
+
+
[email protected]
+def _output_to(log_file) -> Iterator[None]:
+    """Send everything written to stdout/stderr during the block to 
``log_file``."""
+    _STDOUT_PROXY.target = log_file
+    _STDERR_PROXY.target = log_file
+    try:
+        with contextlib.redirect_stdout(_STDOUT_PROXY), 
contextlib.redirect_stderr(_STDERR_PROXY):
+            yield
+    finally:
+        _STDOUT_PROXY.target = None
+        _STDERR_PROXY.target = None
+
+
[email protected]
+def _working_directory(path: Path) -> Iterator[None]:
+    previous = os.getcwd()
+    os.chdir(path)
+    try:
+        yield
+    finally:
+        os.chdir(previous)
+
+
[email protected]
+def _sys_path_prepended(paths: list[Path]) -> Iterator[None]:
+    entries = [path.as_posix() for path in paths]
+    sys.path[:0] = entries
+    try:
+        yield
+    finally:
+        for entry in entries:
+            with contextlib.suppress(ValueError):
+                sys.path.remove(entry)
+
+
[email protected]
+def _build_timeout(seconds: int) -> Iterator[None]:
+    """Abort a runaway in-process build the way the previous subprocess 
timeout did."""
+    if not hasattr(signal, "SIGALRM") or threading.current_thread() is not 
threading.main_thread():
+        yield
+        return
+
+    def _raise_timeout(signum, frame):
+        raise TimeoutError(f"Sphinx build did not finish within {seconds} 
seconds")
+
+    previous_handler = signal.signal(signal.SIGALRM, _raise_timeout)
+    signal.alarm(seconds)
+    try:
+        yield
+    finally:
+        signal.alarm(0)
+        signal.signal(signal.SIGALRM, previous_handler)
+
 
 class AirflowDocsBuilder:
     """Documentation builder for Airflow."""
@@ -181,25 +302,12 @@ class AirflowDocsBuilder:
         ]
         if os.environ.get("CI", "") != "true" and verbose:
             console.print("[yellow]Command to run:[/] ", " 
".join([shlex.quote(arg) for arg in build_cmd]))
-        env = os.environ.copy()
-        env["AIRFLOW_PACKAGE_NAME"] = self.package_name
-        if self.pythonpath:
-            env["PYTHONPATH"] = ":".join([path.as_posix() for path in 
self.pythonpath])
         if verbose:
             console.print(
                 f"[bright_blue]{self.package_name:60}:[/] The output is hidden 
until an error occurs."
             )
-        with open(self.log_spelling_filename, "w") as output:
-            completed_proc = run(
-                build_cmd,
-                check=False,
-                cwd=AIRFLOW_CONTENT_ROOT_PATH,
-                env=env,
-                stdout=output if not verbose else None,
-                stderr=output if not verbose else None,
-                timeout=PROCESS_TIMEOUT,
-            )
-        if completed_proc.returncode != 0:
+        returncode = self._run_sphinx(build_cmd, 
log_file=self.log_spelling_filename, verbose=verbose)
+        if returncode != 0:
             spelling_errors.append(
                 SpellingError(
                     file_path=None,
@@ -207,9 +315,7 @@ class AirflowDocsBuilder:
                     spelling=None,
                     suggestion=None,
                     context_line=None,
-                    message=(
-                        f"Sphinx spellcheck returned non-zero exit status: 
{completed_proc.returncode}."
-                    ),
+                    message=f"Sphinx spellcheck returned non-zero exit status: 
{returncode}.",
                 )
             )
             spelling_warning_text = ""
@@ -263,31 +369,18 @@ class AirflowDocsBuilder:
         ]
         if os.environ.get("CI", "") != "true" and verbose:
             console.print("[yellow]Command to run:[/] ", " 
".join([shlex.quote(arg) for arg in build_cmd]))
-        env = os.environ.copy()
-        env["AIRFLOW_PACKAGE_NAME"] = self.package_name
-        if self.pythonpath:
-            env["PYTHONPATH"] = ":".join([path.as_posix() for path in 
self.pythonpath])
         if verbose:
             console.print(
                 f"[bright_blue]{self.package_name:60}:[/] Running sphinx. "
                 f"The output is hidden until an error occurs."
             )
-        with open(self.log_build_filename, "w") as output:
-            completed_proc = run(
-                build_cmd,
-                check=False,
-                cwd=AIRFLOW_CONTENT_ROOT_PATH,
-                env=env,
-                stdout=output if not verbose else None,
-                stderr=output if not verbose else None,
-                timeout=PROCESS_TIMEOUT,
-            )
-        if completed_proc.returncode != 0:
+        returncode = self._run_sphinx(build_cmd, 
log_file=self.log_build_filename, verbose=verbose)
+        if returncode != 0:
             build_errors.append(
                 DocBuildError(
                     file_path=None,
                     line_no=None,
-                    message=f"Sphinx returned non-zero exit status: 
{completed_proc.returncode}.",
+                    message=f"Sphinx returned non-zero exit status: 
{returncode}.",
                 )
             )
         if self.log_build_warning_filename.is_file():
@@ -308,6 +401,42 @@ class AirflowDocsBuilder:
     def get_command(self) -> str:
         return "sphinx-autobuild" if self.is_autobuild else "sphinx-build"
 
+    def _run_sphinx(self, build_cmd: list[str], *, log_file: Path, verbose: 
bool) -> int:
+        """
+        Run a ``sphinx-build`` / ``sphinx-autobuild`` command line and return 
its exit status.
+
+        ``sphinx-build`` runs in the current process (see the module comment 
for why); its output goes
+        to ``log_file`` unless ``verbose`` is set. ``sphinx-autobuild`` is a 
long-running server and
+        keeps running as a subprocess.
+        """
+        if self.is_autobuild:
+            env = os.environ.copy()
+            env["AIRFLOW_PACKAGE_NAME"] = self.package_name
+            if self.pythonpath:
+                env["PYTHONPATH"] = ":".join([path.as_posix() for path in 
self.pythonpath])
+            with open(log_file, "w") as output:
+                completed_proc = run(
+                    build_cmd,
+                    check=False,
+                    cwd=AIRFLOW_CONTENT_ROOT_PATH,
+                    env=env,
+                    stdout=output if not verbose else None,
+                    stderr=output if not verbose else None,
+                    timeout=PROCESS_TIMEOUT,
+                )
+            return completed_proc.returncode
+        _forget_sphinx_conf_modules()
+        os.environ["AIRFLOW_PACKAGE_NAME"] = self.package_name
+        with (
+            open(log_file, "w") as output,
+            _output_to(output) if not verbose else contextlib.nullcontext(),
+            _working_directory(AIRFLOW_CONTENT_ROOT_PATH),
+            _sys_path_prepended(self.pythonpath),
+            _build_timeout(PROCESS_TIMEOUT),
+        ):
+            # Sphinx reports a TimeoutError raised by the alarm like any other 
build failure.
+            return build_main(build_cmd[1:])
+
 
 def get_available_providers_distributions(include_suspended: bool = False):
     """Get list of all available providers packages to build."""
diff --git a/devel-common/src/sphinx_exts/provider_yaml_utils.py 
b/devel-common/src/sphinx_exts/provider_yaml_utils.py
index 9fc02b61f3c..324876be6fd 100644
--- a/devel-common/src/sphinx_exts/provider_yaml_utils.py
+++ b/devel-common/src/sphinx_exts/provider_yaml_utils.py
@@ -23,6 +23,7 @@ from pathlib import Path
 from typing import Any
 
 import jsonschema
+import jsonschema.validators
 import yaml
 
 AIRFLOW_ROOT_PATH = Path(__file__).parents[3].resolve()
@@ -31,6 +32,8 @@ AIRFLOW_PROVIDERS_SRC = AIRFLOW_PROVIDERS_PATH / "src"
 PROVIDER_DATA_SCHEMA_PATH = (
     AIRFLOW_ROOT_PATH / "airflow-core" / "src" / "airflow" / 
"provider.yaml.schema.json"
 )
+# The C loader parses YAML several times faster than the pure-Python one; fall 
back when libyaml is absent.
+_YAML_LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
 
 
 @cache
@@ -65,24 +68,31 @@ def get_all_provider_yaml_paths() -> list[Path]:
 
 
 @cache
-def load_package_data(include_suspended: bool = False) -> list[dict[str, Any]]:
-    """
-    Load all data from providers files
+def _provider_yaml_validator() -> jsonschema.protocols.Validator:
+    """Return a validator compiled once for the provider.yaml schema.
 
-    :return: A list containing the contents of all provider.yaml files - old 
and new structure.
+    ``jsonschema.validate`` re-validates the schema itself on every call, 
which is the dominant
+    cost when validating ~100 provider.yaml files in a row (several seconds 
per Sphinx process).
     """
     schema = provider_yaml_schema()
+    validator_cls = jsonschema.validators.validator_for(schema)
+    validator_cls.check_schema(schema)
+    return validator_cls(schema)
+
+
+@cache
+def _load_all_provider_yamls() -> tuple[dict[str, Any], ...]:
+    """Parse and validate every provider.yaml once per process, suspended 
providers included."""
+    validator = _provider_yaml_validator()
     result = []
     for provider_yaml_path in get_all_provider_yaml_paths():
         with open(provider_yaml_path) as yaml_file:
-            provider = yaml.safe_load(yaml_file)
+            provider = yaml.load(yaml_file, Loader=_YAML_LOADER)
         try:
-            jsonschema.validate(provider, schema=schema)
+            validator.validate(provider)
         except jsonschema.ValidationError as ex:
             msg = f"Unable to parse: {provider_yaml_path}. Original error 
{type(ex).__name__}: {ex}"
             raise RuntimeError(msg)
-        if provider["state"] == "suspended" and not include_suspended:
-            continue
         provider_yaml_dir_str = os.path.dirname(provider_yaml_path)
         module = provider["package-name"][len("apache-") :].replace("-", ".")
         module_folder = module[len("airflow-providers-") :].replace(".", "/")
@@ -91,4 +101,18 @@ def load_package_data(include_suspended: bool = False) -> 
list[dict[str, Any]]:
         provider["docs-dir"] = os.path.dirname(provider_yaml_path.parent / 
"docs")
         provider["system-tests-dir"] = 
f"{provider_yaml_dir_str}/tests/system/{module_folder}"
         result.append(provider)
-    return result
+    return tuple(result)
+
+
+@cache
+def load_package_data(include_suspended: bool = False) -> list[dict[str, Any]]:
+    """
+    Load all data from providers files
+
+    :return: A list containing the contents of all provider.yaml files - old 
and new structure.
+    """
+    return [
+        provider
+        for provider in _load_all_provider_yamls()
+        if include_suspended or provider["state"] != "suspended"
+    ]
diff --git 
a/devel-common/tests/unit/sphinx_exts/docs_build/test_build_docs_scheduling.py 
b/devel-common/tests/unit/sphinx_exts/docs_build/test_build_docs_scheduling.py
new file mode 100644
index 00000000000..5e827401061
--- /dev/null
+++ 
b/devel-common/tests/unit/sphinx_exts/docs_build/test_build_docs_scheduling.py
@@ -0,0 +1,41 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest import mock
+
+from docs import build_docs
+
+
[email protected](build_docs, "_estimated_build_weight", autospec=True)
+def test_sort_heaviest_first_orders_by_weight_then_name(mock_weight):
+    weights = {"google": 3000, "amazon": 1500, "ftp": 20, "ssh": 20, 
"docker-stack": 5}
+    mock_weight.side_effect = weights.__getitem__
+
+    ordered = build_docs.sort_heaviest_first(["ssh", "docker-stack", "google", 
"ftp", "amazon"])
+
+    assert ordered == ["google", "amazon", "ftp", "ssh", "docker-stack"]
+
+
+def test_estimated_build_weight_ranks_real_packages_sensibly():
+    google = 
build_docs._estimated_build_weight("apache-airflow-providers-google")
+    core = build_docs._estimated_build_weight("apache-airflow")
+    ftp = build_docs._estimated_build_weight("apache-airflow-providers-ftp")
+
+    assert google > ftp > 0
+    assert core > ftp
+    assert build_docs._estimated_build_weight("docker-stack") == 0
diff --git 
a/devel-common/tests/unit/sphinx_exts/docs_build/test_docs_builder.py 
b/devel-common/tests/unit/sphinx_exts/docs_build/test_docs_builder.py
new file mode 100644
index 00000000000..bd68b2af52d
--- /dev/null
+++ b/devel-common/tests/unit/sphinx_exts/docs_build/test_docs_builder.py
@@ -0,0 +1,146 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import io
+import os
+import subprocess
+import sys
+import types
+from unittest import mock
+
+from sphinx_exts.docs_build import docs_builder
+from sphinx_exts.docs_build.code_utils import AIRFLOW_CONTENT_ROOT_PATH, 
DOCS_SOURCES_PATH
+from sphinx_exts.docs_build.docs_builder import (
+    AirflowDocsBuilder,
+    _forget_sphinx_conf_modules,
+    _RedirectableStream,
+)
+
+
+class TestRedirectableStream:
+    def test_writes_go_to_target_while_set_and_to_fallback_otherwise(self):
+        fallback = io.StringIO()
+        target = io.StringIO()
+        stream = _RedirectableStream(fallback)
+
+        stream.write("before\n")
+        stream.target = target
+        stream.write("during\n")
+        stream.target = None
+        stream.write("after\n")
+
+        assert fallback.getvalue() == "before\nafter\n"
+        assert target.getvalue() == "during\n"
+
+    def 
test_reference_kept_from_an_earlier_build_stays_usable_after_its_log_is_closed(self):
+        """A library that cached the stream during build 1 must not hit a 
closed file in build 2."""
+        fallback = io.StringIO()
+        stream = _RedirectableStream(fallback)
+        first_log = io.StringIO()
+        stream.target = first_log
+        cached_by_library = stream
+        stream.target = None
+        first_log.close()
+        second_log = io.StringIO()
+        stream.target = second_log
+
+        cached_by_library.write("from build 2")
+
+        assert second_log.getvalue() == "from build 2"
+        assert stream.isatty() is False
+        assert stream.encoding
+
+
+class TestForgetSphinxConfModules:
+    def 
test_forgets_conf_modules_but_keeps_build_script_and_unrelated_modules(self, 
monkeypatch):
+        conf_module = types.ModuleType("docs.provider_conf")
+        conf_module.__file__ = (DOCS_SOURCES_PATH / 
"provider_conf.py").as_posix()
+        constants_module = types.ModuleType("docs.utils.conf_constants")
+        constants_module.__file__ = (DOCS_SOURCES_PATH / "utils" / 
"conf_constants.py").as_posix()
+        build_script = types.ModuleType("docs.build_docs")
+        build_script.__file__ = (DOCS_SOURCES_PATH / 
"build_docs.py").as_posix()
+        elsewhere = types.ModuleType("docs.elsewhere")
+        elsewhere.__file__ = "/somewhere/else/docs/elsewhere.py"
+        for module in (conf_module, constants_module, build_script, elsewhere):
+            monkeypatch.setitem(sys.modules, module.__name__, module)
+
+        _forget_sphinx_conf_modules()
+
+        assert "docs.provider_conf" not in sys.modules
+        assert "docs.utils.conf_constants" not in sys.modules
+        assert sys.modules["docs.build_docs"] is build_script
+        assert sys.modules["docs.elsewhere"] is elsewhere
+
+
+class TestRunSphinxInProcess:
+    def 
test_runs_build_main_with_output_in_log_file_and_restores_process_state(self, 
tmp_path, monkeypatch):
+        builder = 
AirflowDocsBuilder(package_name="apache-airflow-providers-ftp")
+        log_file = tmp_path / "build.log"
+        previous_cwd = os.getcwd()
+        previous_sys_path = list(sys.path)
+        seen: dict[str, object] = {}
+
+        def fake_build_main(argv):
+            seen["argv"] = list(argv)
+            seen["cwd"] = os.getcwd()
+            seen["package"] = os.environ.get("AIRFLOW_PACKAGE_NAME")
+            print("sphinx says hi")
+            print("sphinx warns", file=sys.stderr)
+            return 0
+
+        monkeypatch.setattr(
+            docs_builder,
+            "build_main",
+            mock.create_autospec(docs_builder.build_main, 
side_effect=fake_build_main),
+        )
+        monkeypatch.setattr(
+            docs_builder,
+            "_forget_sphinx_conf_modules",
+            mock.create_autospec(docs_builder._forget_sphinx_conf_modules),
+        )
+
+        returncode = builder._run_sphinx(
+            ["sphinx-build", "-T", "-b", "html", "src", "out"], 
log_file=log_file, verbose=False
+        )
+
+        assert returncode == 0
+        assert seen["argv"] == ["-T", "-b", "html", "src", "out"]
+        assert seen["cwd"] == AIRFLOW_CONTENT_ROOT_PATH.as_posix()
+        assert seen["package"] == "apache-airflow-providers-ftp"
+        assert log_file.read_text() == "sphinx says hi\nsphinx warns\n"
+        assert os.getcwd() == previous_cwd
+        assert sys.path == previous_sys_path
+        docs_builder._forget_sphinx_conf_modules.assert_called_once_with()
+
+    def test_autobuild_stays_a_subprocess(self, tmp_path, monkeypatch):
+        builder = 
AirflowDocsBuilder(package_name="apache-airflow-providers-ftp")
+        builder.is_autobuild = True
+        run = mock.create_autospec(
+            docs_builder.run, 
return_value=mock.Mock(spec=subprocess.CompletedProcess, returncode=3)
+        )
+        monkeypatch.setattr(docs_builder, "run", run)
+        monkeypatch.setattr(docs_builder, "build_main", 
mock.create_autospec(docs_builder.build_main))
+
+        returncode = builder._run_sphinx(
+            ["sphinx-autobuild", "src", "out"], log_file=tmp_path / "log", 
verbose=False
+        )
+
+        assert returncode == 3
+        run.assert_called_once()
+        assert run.call_args.args[0] == ["sphinx-autobuild", "src", "out"]
+        docs_builder.build_main.assert_not_called()
diff --git a/devel-common/tests/unit/sphinx_exts/test_airflow_intersphinx.py 
b/devel-common/tests/unit/sphinx_exts/test_airflow_intersphinx.py
new file mode 100644
index 00000000000..ce5b77ad9ba
--- /dev/null
+++ b/devel-common/tests/unit/sphinx_exts/test_airflow_intersphinx.py
@@ -0,0 +1,113 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest import mock
+
+import pytest
+from sphinx.application import Sphinx
+from sphinx.builders import Builder
+from sphinx.environment import BuildEnvironment
+from sphinx.errors import SphinxError
+
+SPHINX_EXTS_PATH = Path(__file__).parents[3] / "src" / "sphinx_exts"
+if SPHINX_EXTS_PATH.as_posix() not in sys.path:
+    # The extensions are loaded by Sphinx from this directory and import each 
other by bare name.
+    sys.path.append(SPHINX_EXTS_PATH.as_posix())
+
+from sphinx_exts import airflow_intersphinx  # noqa: E402
+
+PACKAGE = "apache-airflow-providers-ftp"
+
+
[email protected]
+def generated_path(tmp_path, monkeypatch):
+    monkeypatch.setattr(airflow_intersphinx, "GENERATED_PATH", tmp_path)
+    return tmp_path
+
+
+def _touch(path: Path) -> Path:
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_bytes(b"")
+    return path
+
+
+class TestInventoryFor:
+    def test_prefers_the_html_inventory_built_in_this_run(self, 
generated_path):
+        build_dir = generated_path / "_build" / "docs" / PACKAGE / "stable"
+        html = _touch(build_dir / "objects.inv")
+        _touch(build_dir / f"output-spelling-results-{PACKAGE}" / 
"objects.inv")
+        _touch(generated_path / "_inventory_cache" / PACKAGE / "objects.inv")
+
+        assert airflow_intersphinx._inventory_for(PACKAGE, versioned=True) == 
html
+
+    def test_uses_the_spelling_inventory_when_only_a_spelling_build_ran(self, 
generated_path):
+        build_dir = generated_path / "_build" / "docs" / PACKAGE / "stable"
+        spelling = _touch(build_dir / f"output-spelling-results-{PACKAGE}" / 
"objects.inv")
+        _touch(generated_path / "_inventory_cache" / PACKAGE / "objects.inv")
+
+        assert airflow_intersphinx._inventory_for(PACKAGE, versioned=True) == 
spelling
+
+    def test_falls_back_to_the_downloaded_inventory(self, generated_path):
+        cached = generated_path / "_inventory_cache" / PACKAGE / "objects.inv"
+
+        assert airflow_intersphinx._inventory_for(PACKAGE, versioned=True) == 
cached
+
+    def test_non_versioned_packages_have_no_stable_directory(self, 
generated_path):
+        html = _touch(generated_path / "_build" / "docs" / "docker-stack" / 
"objects.inv")
+
+        assert airflow_intersphinx._inventory_for("docker-stack", 
versioned=False) == html
+
+
+class TestDumpInventoryAfterSpellingBuild:
+    @staticmethod
+    def _app(builder_name: str, outdir: Path) -> mock.Mock:
+        app = mock.Mock(spec=Sphinx)
+        app.builder = mock.Mock(spec=Builder)
+        app.builder.name = builder_name
+        app.outdir = outdir
+        app.env = mock.Mock(spec=BuildEnvironment)
+        return app
+
+    @mock.patch.object(airflow_intersphinx.InventoryFile, "dump", 
autospec=True)
+    def test_writes_an_inventory_into_the_spelling_output_dir(self, mock_dump, 
tmp_path):
+        app = self._app("spelling", tmp_path)
+
+        airflow_intersphinx._dump_inventory_after_spelling_build(app, None)
+
+        mock_dump.assert_called_once()
+        filename, env, uri_builder = mock_dump.call_args.args
+        assert filename == (tmp_path / "objects.inv").as_posix()
+        assert env is app.env
+        assert uri_builder.get_target_uri("operators/index") == 
"operators/index.html"
+
+    @pytest.mark.parametrize(
+        ("builder_name", "exception"),
+        [
+            pytest.param("html", None, id="html-builder-writes-its-own"),
+            pytest.param("spelling", SphinxError("boom"), id="failed-build"),
+        ],
+    )
+    @mock.patch.object(airflow_intersphinx.InventoryFile, "dump", 
autospec=True)
+    def test_skips_other_builders_and_failed_builds(self, mock_dump, tmp_path, 
builder_name, exception):
+        app = self._app(builder_name, tmp_path)
+
+        airflow_intersphinx._dump_inventory_after_spelling_build(app, 
exception)
+
+        mock_dump.assert_not_called()

Reply via email to