From a1b04bd186208bb4ba65671d2dd2d801a3f53043 Mon Sep 17 00:00:00 2001 From: Alexander Lanin Date: Wed, 9 Sep 2026 12:14:18 +0200 Subject: [PATCH] refactor: docs CLI --- docs.bzl | 12 +- src/BUILD | 14 +- src/docs_cli/BUILD | 53 +++++ src/docs_cli/README.md | 78 ++++++ src/{incremental.py => docs_cli/cli.py} | 194 ++++++++------- .../dirty_build_test.py} | 64 ++++- src/docs_cli/main_test.py | 223 ++++++++++++++++++ src/extensions/docs/mounts_internals.rst | 2 +- .../tests/module_verification_reports_test.py | 2 +- 9 files changed, 525 insertions(+), 117 deletions(-) create mode 100644 src/docs_cli/BUILD create mode 100644 src/docs_cli/README.md rename src/{incremental.py => docs_cli/cli.py} (79%) rename src/{incremental_dirty_build_test.py => docs_cli/dirty_build_test.py} (70%) create mode 100644 src/docs_cli/main_test.py diff --git a/docs.bzl b/docs.bzl index b955d7b01..e2a8d2d1f 100644 --- a/docs.bzl +++ b/docs.bzl @@ -503,12 +503,13 @@ def _sphinx_runtime_deps(deps): result.append(fixed_dep) return result -def _declare_docs_binary(name, srcs, data, deps, env, action): +def _declare_docs_binary(name, data, deps, env, action): """Declare one of the interactive documentation command targets.""" + docs_cli_src = Label("//src/docs_cli:cli.py") command_env = env | {"ACTION": action} py_binary( name = name, - srcs = srcs, + srcs = [docs_cli_src], data = data, deps = deps, env = command_env, @@ -632,7 +633,6 @@ def docs( Label("//src/extensions/score_sphinx_bundle:score_sphinx_bundle"), ] - incremental_src = Label("//src:incremental.py") known_good_label = [known_good] if known_good else [] @@ -706,7 +706,7 @@ def docs( docs_env["SPHINX_CONFIG_FILE"] = "$(rlocationpath " + sphinx_config + ")" if metamodel: # The interactive ``py_binary`` targets run from a runfiles tree. - # incremental.py resolves this logical path through ``RUNFILES_DIR``. + # docs_cli resolves this logical path through ``RUNFILES_DIR``. docs_env["SCORE_METAMODEL_YAML"] = "$(rlocationpath " + str(metamodel) + ")" if known_good_label: known_good_str = str(known_good_label[0]) @@ -718,7 +718,6 @@ def docs( # ``docs``; expose this binary via the alias below instead. _declare_docs_binary( name = "_score_docs_cli", - srcs = [incremental_src], data = docs_data, deps = deps, env = docs_env, @@ -733,7 +732,6 @@ def docs( _declare_docs_binary( name = "docs_link_check", - srcs = [incremental_src], data = docs_data, deps = deps, env = docs_env, @@ -741,7 +739,6 @@ def docs( ) _declare_docs_binary( name = "docs_check", - srcs = [incremental_src], data = docs_data, deps = deps, env = docs_env, @@ -749,7 +746,6 @@ def docs( ) _declare_docs_binary( name = "live_preview", - srcs = [incremental_src], data = docs_data, deps = deps, env = docs_env, diff --git a/src/BUILD b/src/BUILD index b7b216854..96a04616d 100644 --- a/src/BUILD +++ b/src/BUILD @@ -12,10 +12,8 @@ # ******************************************************************************* load("@aspect_rules_py//py:defs.bzl", "py_library") -load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") load("@rules_java//java:java_binary.bzl", "java_binary") load("@rules_python//python:pip.bzl", "compile_pip_requirements") -load("//:score_pytest.bzl", "score_pytest") # These are only exported because they're passed as files to the //docs.bzl # macros, and thus must be visible to other packages. They should only be @@ -25,28 +23,18 @@ exports_files( [ "requirements.txt", "requirements.in", - "incremental.py", "dummy.py", "generate_sourcelinks_cli.py", ], visibility = ["//visibility:public"], ) -score_pytest( - name = "incremental_dirty_build_test", - srcs = [ - "incremental_dirty_build_test.py", - "incremental.py", - ], - deps = all_requirements + ["//src/extensions/score_sphinx_bundle:score_sphinx_bundle"], - pytest_config = "//:pyproject.toml", -) - filegroup( name = "all_sources", srcs = glob( ["*.py"], ) + [ + "//src/docs_cli:all_sources", "//src/extensions/score_draw_uml_funcs:all_sources", "//src/extensions/score_layout:all_sources", "//src/extensions/score_metamodel:all_sources", diff --git a/src/docs_cli/BUILD b/src/docs_cli/BUILD new file mode 100644 index 000000000..93f61d894 --- /dev/null +++ b/src/docs_cli/BUILD @@ -0,0 +1,53 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@docs_as_code_hub_env//:requirements.bzl", "all_requirements") +load("//:score_pytest.bzl", "score_pytest") + +# docs.bzl passes this entry script as the source of each interactive binary. +# Exporting it allows docs() to declare those binaries in consumer packages. +exports_files( + ["cli.py"], + visibility = ["//visibility:public"], +) + +# The parent source collection must cross this Bazel package boundary +# explicitly so source-code linking continues to include the CLI and tests. +filegroup( + name = "all_sources", + srcs = glob(["*.py"]), + visibility = ["//visibility:public"], +) + +score_pytest( + name = "dirty_build_test", + srcs = ["dirty_build_test.py", "cli.py"], + deps = all_requirements + [ + "//src/extensions/score_mounts", + "//src/helper_lib", + ], + pytest_config = "//:pyproject.toml", +) + +# Exercise dispatch and the docs.bzl environment contract with the same +# entry script used by the interactive binaries. Its cli.py name also keeps +# it distinct from score_pytest's own main.py entry in the test sources. +score_pytest( + name = "main_test", + srcs = ["main_test.py", "cli.py"], + deps = all_requirements + [ + "//src/extensions/score_mounts", + "//src/helper_lib", + ], + pytest_config = "//:pyproject.toml", +) diff --git a/src/docs_cli/README.md b/src/docs_cli/README.md new file mode 100644 index 000000000..4d83b8ccd --- /dev/null +++ b/src/docs_cli/README.md @@ -0,0 +1,78 @@ + + +# Documentation CLI + +This package runs local documentation builds and live preview for the targets +created by [`docs()`](../../../docs.bzl). Bazel supplies the source files, +configuration, dependencies and environment for each invocation. + +## Commands + +Run these targets in the package that calls `docs()`. The examples assume the +workspace root; for a nested package, use a label such as `//component:docs`. + +| Command | Action | Result | +| --- | --- | --- | +| `bazel run //:docs` | `incremental` | Build HTML, reusing existing Sphinx output where possible. | +| `bazel run //:docs_check` | `check` | Run the Sphinx `needs` builder. | +| `bazel run //:docs_link_check` | `linkcheck` | Run the Sphinx `linkcheck` builder. | +| `bazel run //:live_preview` | `live_preview` | Rebuild on edits and serve the documentation with sphinx-autobuild. | + + +## Layout and Bazel integration + +- `cli.py` contains the complete implementation: CLI parsing, Sphinx arguments, + cache checks, bundle watch directories and action dispatch. +- `dirty_build_test.py` covers cache invalidation and mounted watch directories. +- `main_test.py` covers dispatch, build results and the Bazel environment contract. + +`_declare_docs_binary()` in `docs.bzl` creates a separate `py_binary` for each +command, using the exported `cli.py` directly as its source. Each binary receives +its own `ACTION`, documentation environment and dependencies from `docs()`. + +The `all_sources` filegroup is included by `//src:all_sources` so source-code +linking can traverse this Bazel package boundary. + +## Configuration and build state + +`docs.bzl` provides `SOURCE_DIRECTORY`, `PACKAGE_DIR`, `DATA`, and optional +configuration such as `SPHINX_CONFIG_FILE`, `SCORE_METAMODEL_YAML`, +`MOUNTS_MANIFEST`, `EXTERNAL_NEEDS_FILES`, `TEST_SOURCES` and `KNOWN_GOOD_JSON`. +Bazel provides the workspace and runfiles locations. The CLI resolves source +and output paths relative to the package containing the `docs()` call; generated +configuration is resolved through runfiles. + +All actions share the package's `_build` directory. Before starting, the CLI +removes stale output if the previous build recorded warnings, the stored hash +is missing, or the contents of `MODULE.bazel`, `MODULE.bazel.lock` or the package's +`BUILD` file have changed. Successful non-preview builds record the input hash; +failed builds append a marker to `warnings.txt` to force cleanup next time. + +Live preview also watches mounted bundle source directories and generated data +directories outside the main Sphinx source directory, using the same mount +resolver as the Sphinx extension. + +## Tests + +From the repository root: + +```sh +bazel test //src/docs_cli:dirty_build_test //src/docs_cli:main_test +``` + +To exercise the entry script and its runfiles with an actual Sphinx build: + +```sh +bazel run //src/tests/docs_bzl/scenarios/basic_docs:docs +``` diff --git a/src/incremental.py b/src/docs_cli/cli.py similarity index 79% rename from src/incremental.py rename to src/docs_cli/cli.py index fb4464bd6..49d895d88 100644 --- a/src/incremental.py +++ b/src/docs_cli/cli.py @@ -11,6 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +"""Run local documentation builds and live preview from Bazel's docs targets.""" + import argparse import hashlib import json @@ -32,12 +34,13 @@ logger = logging.getLogger(__name__) + _MODULE_HASH_FILE = ".module_bazel_hash" def get_env(name: str) -> str: - val = os.environ.get(name, None) - logger.debug(f"DEBUG: Env: {name} = {val}") + val = os.environ.get(name) + logger.debug("Env: %s = %s", name, val) if val is None: raise ValueError(f"Environment variable {name} is not set") return val @@ -93,7 +96,7 @@ def update_module_hash(build_dir: Path, sentinel_files: list[Path]) -> None: (build_dir / _MODULE_HASH_FILE).write_text(_compute_hash(sentinel_files)) -def _mounted_watch_dirs( +def mounted_watch_dirs( manifest_path: Path, ws_root: Path | None, runfiles_dir: Path | None = None ) -> list[str]: """Return the directories provided by docs bundles for ``sphinx-autobuild``. @@ -141,52 +144,14 @@ def add_watch_dir(path: Path) -> None: return watch_dirs -if __name__ == "__main__": - parser = argparse.ArgumentParser() - # Add debugging functionality - parser.add_argument( - "-dp", "--debug_port", help="port to listen to debugging client", default=5678 - ) - parser.add_argument( - "--debug", help="Enable Debugging via debugpy", action="store_true" - ) - parser.add_argument("--github_user", help=argparse.SUPPRESS) - parser.add_argument("--github_repo", help=argparse.SUPPRESS) - parser.add_argument( - "--port", - type=int, - help="Port to use for the live_preview ACTION. Default is 8000. " - "Use 0 for auto detection of a free port.", - default=8000, - ) - - args = parser.parse_args() - if args.debug: - debugpy.listen(("0.0.0.0", args.debug_port)) - logger.info("Waiting for client to connect on port: " + str(args.debug_port)) - debugpy.wait_for_client() - - ws_root = Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) - # Docs source and output are resolved relative to the package where docs() - # was called. For the root BUILD, PACKAGE_DIR == "" so this is unchanged. - package_dir = ws_root / os.environ.get("PACKAGE_DIR", "") - - build_dir = package_dir / "_build" - sentinel_files = [ - ws_root / "MODULE.bazel", - ws_root / "MODULE.bazel.lock", - package_dir / "BUILD", - ] - clean_builddir_if_stale(build_dir, sentinel_files) - - warning_file = build_dir / "warnings.txt" - +def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[str]: + """Resolve package sources and Bazel-provided configuration for every builder.""" source_directory = get_env("SOURCE_DIRECTORY") base_arguments = [ str(package_dir / source_directory), str(build_dir), "--warning-file", - str(warning_file), + str(build_dir / "warnings.txt"), "-W", # treat warning as errors "--keep-going", # do not abort after one error "-T", # show details in case of errors in extensions @@ -240,25 +205,78 @@ def add_watch_dir(path: Path) -> None: if os.getenv("KNOWN_GOOD_JSON"): base_arguments.append(f"--define=KNOWN_GOOD_JSON={get_env('KNOWN_GOOD_JSON')}") + return base_arguments + + +def watch_arguments() -> list[str]: + """Build autobuild options using the same runfiles resolution as Sphinx.""" + mounts_manifest = os.environ.get("MOUNTS_MANIFEST", "") + watch_arguments: list[str] = [] + if mounts_manifest: + # ``MOUNTS_MANIFEST`` is runfiles-relative under ``bazel run`` and + # an ordinary path for direct invocations, matching score_mounts. + ws_root = find_ws_root() + manifest_path = ( + get_runfiles_dir() / mounts_manifest + if ws_root is not None + else Path(mounts_manifest) + ) + for watch_dir in mounted_watch_dirs( + manifest_path, + ws_root, + get_runfiles_dir() if ws_root is not None else None, + ): + watch_arguments.extend(["--watch", watch_dir]) + return watch_arguments + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "-dp", "--debug_port", help="port to listen to debugging client", default=5678 + ) + parser.add_argument( + "--debug", help="Enable Debugging via debugpy", action="store_true" + ) + parser.add_argument("--github_user", help=argparse.SUPPRESS) + parser.add_argument("--github_repo", help=argparse.SUPPRESS) + parser.add_argument( + "--port", + type=int, + help="Port to use for the live_preview ACTION. Default is 8000. " + "Use 0 for auto detection of a free port.", + default=8000, + ) + + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Run the requested builder and record whether its output can be reused.""" + args = parse_args(argv) + if args.debug: + debugpy.listen(("0.0.0.0", args.debug_port)) + logger.info("Waiting for client to connect on port: " + str(args.debug_port)) + debugpy.wait_for_client() + + ws_root = Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) + # Docs source and output are resolved relative to the package where docs() + # was called; an empty PACKAGE_DIR denotes the workspace root. + package_dir = ws_root / os.environ.get("PACKAGE_DIR", "") + + build_dir = package_dir / "_build" + sentinel_files = [ + ws_root / "MODULE.bazel", + ws_root / "MODULE.bazel.lock", + package_dir / "BUILD", + ] + clean_builddir_if_stale(build_dir, sentinel_files) + + warning_file = build_dir / "warnings.txt" + base_arguments = sphinx_arguments(ws_root, package_dir, build_dir) + action = get_env("ACTION") if action == "live_preview": - mounts_manifest = os.environ.get("MOUNTS_MANIFEST", "") - watch_arguments: list[str] = [] - if mounts_manifest: - # ``MOUNTS_MANIFEST`` is runfiles-relative under ``bazel run`` and - # an ordinary path for direct invocations, matching score_mounts. - manifest_path = ( - get_runfiles_dir() / mounts_manifest - if find_ws_root() - else Path(mounts_manifest) - ) - ws_root = find_ws_root() - for watch_dir in _mounted_watch_dirs( - manifest_path, - ws_root, - get_runfiles_dir() if ws_root is not None else None, - ): - watch_arguments.extend(["--watch", watch_dir]) sphinx_autobuild_main( base_arguments + [ @@ -266,35 +284,35 @@ def add_watch_dir(path: Path) -> None: "--define=skip_rescanning_via_source_code_linker=1", f"--port={args.port}", ] - + watch_arguments + + watch_arguments() ) + return 0 + + if action == "incremental": + builder = "html" + elif action == "check": + builder = "needs" + elif action == "linkcheck": + builder = "linkcheck" else: - if action == "incremental": - builder = "html" - elif action == "check": - builder = "needs" - elif action == "linkcheck": - builder = "linkcheck" - else: - raise ValueError(f"Unknown action: {action}") - - base_arguments.extend( - [ - "-b", - builder, - ] - ) + raise ValueError(f"Unknown action: {action}") - start_time = time.perf_counter() - exit_code = sphinx_main(base_arguments) - end_time = time.perf_counter() - print(f"docs ({action}) finished in {end_time - start_time:.1f} seconds") + base_arguments.extend(["-b", builder]) - if exit_code == 0: - update_module_hash(build_dir, sentinel_files) - else: - with warning_file.open("a", encoding="utf-8") as f: - f.write("-" * 80 + "\n") - f.write(f"Build failed with exit code {exit_code}\n") + start_time = time.perf_counter() + exit_code = sphinx_main(base_arguments) + end_time = time.perf_counter() + print(f"docs ({action}) finished in {end_time - start_time:.1f} seconds") - sys.exit(exit_code) + if exit_code == 0: + update_module_hash(build_dir, sentinel_files) + else: + with warning_file.open("a", encoding="utf-8") as f: + f.write("-" * 80 + "\n") + f.write(f"Build failed with exit code {exit_code}\n") + + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/incremental_dirty_build_test.py b/src/docs_cli/dirty_build_test.py similarity index 70% rename from src/incremental_dirty_build_test.py rename to src/docs_cli/dirty_build_test.py index 6181f0362..f8e82281c 100644 --- a/src/incremental_dirty_build_test.py +++ b/src/docs_cli/dirty_build_test.py @@ -11,22 +11,36 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -# Unit Tests of incremental.py - import json from pathlib import Path +import pytest from pyfakefs.fake_filesystem import FakeFilesystem as FFS -from incremental import ( - _mounted_watch_dirs, # pyright: ignore[reportPrivateUsage] - white-box unit test +from src.docs_cli import cli as docs_cli +from src.docs_cli.cli import ( clean_builddir_if_stale, + mounted_watch_dirs, update_module_hash, ) _BUILD = Path("/build") _MODULE = Path("/MODULE.bazel") _LOCK = Path("/MODULE.bazel.lock") +_WORKSPACE = Path("/workspace") + + +@pytest.fixture +def docs_workspace(fs: FFS, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create the minimal workspace environment used by ``cli.main``.""" + monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(_WORKSPACE)) + monkeypatch.setenv("PACKAGE_DIR", "component") + monkeypatch.setenv("SOURCE_DIRECTORY", "docs") + monkeypatch.setenv("DATA", "[]") + fs.create_dir(_WORKSPACE / "component") + for name in ("MODULE.bazel", "MODULE.bazel.lock", "component/BUILD"): + fs.create_file(_WORKSPACE / name, contents="stable") + return _WORKSPACE def _simulate_old_state(fs: FFS, warnings: str | None) -> None: @@ -119,6 +133,44 @@ def test_missing_hash_file_triggers_clean(fs: FFS) -> None: assert not _BUILD.exists() +@pytest.mark.parametrize( + "action,builder", + [ + ("incremental", "html"), + ("check", "needs"), + ("linkcheck", "linkcheck"), + ], +) +def test_successful_build_reuses_output_until_module_changes( + docs_workspace: Path, + monkeypatch: pytest.MonkeyPatch, + action: str, + builder: str, +) -> None: + """A successful CLI run records a reusable cache and invalidates it on changes.""" + monkeypatch.setenv("ACTION", action) + build_dir = docs_workspace / "component/_build" + reused: list[bool] = [] + + def build(arguments: list[str]) -> int: + assert arguments[-2:] == ["-b", builder] + reused.append((build_dir / "output").exists()) + build_dir.mkdir(exist_ok=True) + (build_dir / "output").touch() + return 0 + + monkeypatch.setattr(docs_cli, "sphinx_main", build) + + # The first run creates the output and records its module-input hash. + assert docs_cli.main([]) == 0 + # An unchanged successful build can reuse the existing output directory. + assert docs_cli.main([]) == 0 + (docs_workspace / "MODULE.bazel.lock").write_text("changed") + # A changed module input forces a clean build before Sphinx runs again. + assert docs_cli.main([]) == 0 + assert reused == [False, True, False] + + def test_mounted_watch_dirs_match_sphinx_mount_paths(tmp_path: Path) -> None: manifest_path = tmp_path / "_mounts_manifest.json" manifest_path.write_text( @@ -144,7 +196,7 @@ def test_mounted_watch_dirs_match_sphinx_mount_paths(tmp_path: Path) -> None: workspace = tmp_path / "workspace" runfiles_dir = tmp_path / "runfiles" - assert _mounted_watch_dirs(manifest_path, workspace, runfiles_dir) == [ + assert mounted_watch_dirs(manifest_path, workspace, runfiles_dir) == [ str(workspace / "extensions/local/docs"), str(runfiles_dir / "vendor+" / "docs"), ] @@ -172,6 +224,6 @@ def test_mounted_watch_dirs_use_data_directories_for_pure_data_bundles( workspace = tmp_path / "workspace" runfiles_dir = tmp_path / "runfiles" - assert _mounted_watch_dirs(manifest_path, workspace, runfiles_dir) == [ + assert mounted_watch_dirs(manifest_path, workspace, runfiles_dir) == [ str(workspace / "bazel-bin/pkg/generated") ] diff --git a/src/docs_cli/main_test.py b/src/docs_cli/main_test.py new file mode 100644 index 000000000..d06a91b3d --- /dev/null +++ b/src/docs_cli/main_test.py @@ -0,0 +1,223 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +from pathlib import Path +from unittest.mock import Mock + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem as FFS + +from src.docs_cli import cli as docs_cli +from src.docs_cli.cli import sphinx_arguments + + +@pytest.fixture +def workspace(fs: FFS, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create the minimum Bazel workspace needed by the CLI tests.""" + + # The CLI reads its configuration from the process environment, so remove + # optional values left behind by the test runner before setting the basics. + ENVIRONMENT_OVERRIDES = ( + "EXTERNAL_NEEDS_FILES", + "TEST_SOURCES", + "MOUNTS_MANIFEST", + "SPHINX_CONFIG_FILE", + "SCORE_METAMODEL_YAML", + "GITHUB_REPOSITORY", + "KNOWN_GOOD_JSON", + "RUNFILES_DIR", + "RUNFILES_MANIFEST_FILE", + ) + for name in ENVIRONMENT_OVERRIDES: + monkeypatch.delenv(name, raising=False) + + workspace = Path("/workspace") + monkeypatch.setenv("BUILD_WORKSPACE_DIRECTORY", str(workspace)) + monkeypatch.setenv("PACKAGE_DIR", "component") + monkeypatch.setenv("SOURCE_DIRECTORY", "docs") + monkeypatch.setenv("DATA", "[]") + monkeypatch.setenv("RUNFILES_DIR", str(workspace / "runfiles")) + + fs.create_dir(workspace / "component") + fs.create_dir(workspace / "runfiles") + for name in ("MODULE.bazel", "MODULE.bazel.lock", "component/BUILD"): + fs.create_file(workspace / name, contents="stable") + return workspace + + +@pytest.mark.parametrize( + "action,builder", + [ + ("incremental", "html"), + ("check", "needs"), + ("linkcheck", "linkcheck"), + ], +) +def test_build_action_selects_sphinx_builder( + workspace: Path, + monkeypatch: pytest.MonkeyPatch, + action: str, + builder: str, +) -> None: + """Each public action invokes Sphinx with its corresponding builder.""" + + # Arrange + monkeypatch.setenv("ACTION", action) + build_dir = workspace / "component/_build" + noop_sphinx = Mock(return_value=0) + monkeypatch.setattr(docs_cli, "sphinx_main", noop_sphinx) + monkeypatch.setattr(docs_cli, "update_module_hash", Mock()) + + # Act + exit_code = docs_cli.main([]) + + # Assert + # The CLI propagates Sphinx's successful result. + assert exit_code == 0 + noop_sphinx.assert_called_once() + arguments = noop_sphinx.call_args.args[0] + # The source and output paths are derived from the Bazel package directory. + assert arguments[:2] == [str(workspace / "component/docs"), str(build_dir)] + # The action selects the builder exposed by its public Bazel target. + assert arguments[-2:] == ["-b", builder] + + +def test_failed_build_returns_exit_code_and_forces_next_build_clean( + workspace: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failure without Sphinx warnings must still invalidate partial output.""" + + # Arrange + monkeypatch.setenv("ACTION", "incremental") + build_dir = workspace / "component/_build" + + def failing_sphinx_mock(arguments: list[str]) -> int: + build_dir.mkdir() + # Simulate a partial build by creating a dummy output file. + # That file must not survive a failed build. + (build_dir / "partial-output").touch() + return 2 + + monkeypatch.setattr(docs_cli, "sphinx_main", failing_sphinx_mock) + + # Act + exit_code = docs_cli.main([]) + + # Assert + # The original Sphinx failure is returned to Bazel. + assert exit_code == 2 + # The failure marker explains why the partial output must not be reused. + assert "Build failed with exit code 2" in (build_dir / "warnings.txt").read_text() + # Failed builds must not record a successful input hash. + assert not (build_dir / ".module_bazel_hash").exists() + + # Arrange + # Use a second replacement to observe whether the failed output was removed. + def rebuild(arguments: list[str]) -> int: + assert not build_dir.exists() + build_dir.mkdir() + return 0 + + monkeypatch.setattr(docs_cli, "sphinx_main", rebuild) + + # Act + rebuild_exit_code = docs_cli.main([]) + + # Assert + # The marker from the failed build causes the next invocation to start clean. + assert rebuild_exit_code == 0 + + +def test_live_preview_uses_port_and_bundle_watches( + workspace: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + monkeypatch.setenv("ACTION", "live_preview") + manifest = workspace / "mounts.json" + manifest.write_text( + '{"mounts": [{"src_root": "extra/docs", "runtime_path": "extra/docs", "mount_at": "extra"}]}' + ) + monkeypatch.setenv("MOUNTS_MANIFEST", str(manifest)) + autobuild = Mock() + monkeypatch.setattr(docs_cli, "sphinx_autobuild_main", autobuild) + + # Act + exit_code = docs_cli.main(["--port", "42424242424"]) + + # Assert + # Live preview exits after handing control to sphinx-autobuild. + assert exit_code == 0 + autobuild.assert_called_once() + arguments = autobuild.call_args.args[0] + # The requested port and source-linker setting are forwarded unchanged. + assert "--port=42424242424" in arguments + assert "--define=skip_rescanning_via_source_code_linker=1" in arguments + # Mounted bundle sources are watched in addition to the main docs tree. + assert arguments[-2:] == ["--watch", str(workspace / "extra/docs")] + # Live preview does not write the successful-build hash. + assert not (workspace / "component/_build/.module_bazel_hash").exists() + + +def test_bazel_configuration_resolves_runfiles_and_preserves_repo_relative_edit_path( + workspace: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + monkeypatch.setenv("SPHINX_CONFIG_FILE", "config/conf.py") + monkeypatch.setenv("SCORE_METAMODEL_YAML", "config/metamodel.yaml") + monkeypatch.setenv("DATA", '[":bundle"]') + monkeypatch.setenv("EXTERNAL_NEEDS_FILES", '["@vendor//:needs"]') + monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") + monkeypatch.setenv("KNOWN_GOOD_JSON", "baseline.json") + package = workspace / "component" + + # Act + arguments = sphinx_arguments(workspace, package, package / "_build") + + # Assert + expected_arguments = { + # Generated configuration and metamodel paths use the runfiles tree. + "-c", + str(workspace / "runfiles/config"), + f"--define=score_metamodel_yaml={workspace}/runfiles/config/metamodel.yaml", + # DATA and EXTERNAL_NEEDS_FILES are passed as one Sphinx define. + '--define=external_needs_source=[":bundle", "@vendor//:needs"]', + # GitHub metadata must keep edit links repository-relative. + "-A=github_user=owner", + "-A=github_repo=repo", + "-A=doc_path=component/docs", + "--define=KNOWN_GOOD_JSON=baseline.json", + } + # Every expected option is present; their relative order is irrelevant here. + assert expected_arguments <= set(arguments) + + +def test_direct_invocation_resolves_metamodel_relative_to_workspace( + workspace: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + # This test covers the non-Bazel fallback, so no runfiles directory exists. + monkeypatch.delenv("RUNFILES_DIR", raising=False) + monkeypatch.setenv("SCORE_METAMODEL_YAML", "metamodel.yaml") + + # Act + arguments = sphinx_arguments(workspace, workspace, workspace / "_build") + + # Assert + # Without Bazel runfiles, the metamodel falls back to the workspace root. + assert f"--define=score_metamodel_yaml={workspace}/metamodel.yaml" in arguments + # A direct invocation has no generated Sphinx config to resolve. + assert "-c" not in arguments diff --git a/src/extensions/docs/mounts_internals.rst b/src/extensions/docs/mounts_internals.rst index 37b516e62..1c73c0b80 100644 --- a/src/extensions/docs/mounts_internals.rst +++ b/src/extensions/docs/mounts_internals.rst @@ -82,7 +82,7 @@ the public ``docs_bzl`` integration suite; do not resolve an external runtime pa relative to the manifest file, because the manifest can live below a Bazel package directory. -Incremental builds use the same resolver in ``src/incremental.py`` to add every +Incremental builds use the same resolver in ``src/docs_cli/cli.py`` to add every mounted directory to ``sphinx-autobuild``'s watch list. Keep these two call sites aligned when the manifest contract changes. diff --git a/tools/tests/module_verification_reports_test.py b/tools/tests/module_verification_reports_test.py index 1bc25a428..764f576c4 100644 --- a/tools/tests/module_verification_reports_test.py +++ b/tools/tests/module_verification_reports_test.py @@ -410,7 +410,7 @@ def test_local_fake_downstream_runs_the_real_docs_target(tmp_path: Path) -> None ) (checkout / ".bazelrc").write_bytes((source_root / ".bazelrc").read_bytes()) # A real downstream clone always carries its own committed lockfile; - # incremental.py hashes it unconditionally as a build-cache sentinel. + # docs_cli hashes it unconditionally as a build-cache sentinel. (checkout / "MODULE.bazel.lock").write_text("{}\n", encoding="utf-8") (checkout / "MODULE.bazel").write_text( 'module(name = "fake_report_consumer")\n'