diff --git a/src/docs_cli/cli.py b/src/docs_cli/cli.py index 56b94475b..43d975df2 100644 --- a/src/docs_cli/cli.py +++ b/src/docs_cli/cli.py @@ -30,7 +30,8 @@ ) from src.extensions.score_mounts._resolver import load_mounts_manifest, resolve_walk_dir -from src.helper_lib import Environment, find_ws_root, get_runfiles_dir +from src.helper_lib import Environment, get_runfiles_dir +from src.helper_lib.config import DocsCliConfig logger = logging.getLogger(__name__) @@ -137,9 +138,13 @@ def add_watch_dir(path: Path) -> None: return watch_dirs -def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[str]: +def sphinx_arguments( + ws_root: Path, + package_dir: Path, + build_dir: Path, + config: DocsCliConfig, +) -> list[str]: """Resolve package sources and Bazel-provided configuration for every builder.""" - is_bazel_build = env.get("ACTION", "") == "build_needs_json" source_directory = env.required_path("SOURCE_DIRECTORY") base_arguments = [ str(package_dir / source_directory), @@ -158,7 +163,7 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ f"--define=mounts_manifest={env.optional_path('MOUNTS_MANIFEST') or ''}", ] - if is_bazel_build: + if config.is_bazel_build: # The Bazel action declares ``build_dir`` as its output tree, and that # tree must contain only the Needs inventory consumed by downstream # actions. Keep Sphinx's internal doctree cache beside it instead of @@ -182,7 +187,7 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ # instead of using runfiles lookup; interactive targets receive a # runfiles-relative path and need that lookup before Sphinx gets the # containing directory. - if is_bazel_build: + if config.is_bazel_build: config_file = config_file.absolute() elif not config_file.is_absolute(): config_file = get_runfiles_dir() / config_file @@ -194,7 +199,7 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ # instead expands the metamodel label to an execution-root path in # SPHINX_EXTRA_OPTS; applying runfiles lookup there would escape the # action's declared inputs. - if not is_bazel_build and not metamodel_yaml.is_absolute(): + if not config.is_bazel_build and not metamodel_yaml.is_absolute(): runfiles_dir = env.optional_path("RUNFILES_DIR") metamodel_yaml = ( runfiles_dir / metamodel_yaml @@ -225,23 +230,22 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ return base_arguments -def watch_arguments() -> list[str]: +def watch_arguments(config: DocsCliConfig) -> list[str]: """Build autobuild options using the same runfiles resolution as Sphinx.""" mounts_manifest = env.optional_path("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 + if config.is_bazel_run else mounts_manifest ) for watch_dir in mounted_watch_dirs( manifest_path, - ws_root, - get_runfiles_dir() if ws_root is not None else None, + config.ws_root, + get_runfiles_dir() if config.is_bazel_run else None, ): watch_arguments.extend(["--watch", watch_dir]) return watch_arguments @@ -276,14 +280,13 @@ def main(argv: list[str] | None = None) -> int: logger.info("Waiting for client to connect on port: " + str(args.debug_port)) debugpy.wait_for_client() - action = env.get("ACTION") - is_bazel_build = action == "build_needs_json" - ws_root = env.optional_path("BUILD_WORKSPACE_DIRECTORY") or Path() + config = DocsCliConfig.from_environment(env) + ws_root = config.ws_root or Path() # 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 / (env.optional_path("PACKAGE_DIR") or Path()) build_dir = package_dir / "_build" - if is_bazel_build: + if config.is_bazel_build: # Bazel owns the action's paths; never use the caller's workspace cache. package_dir = Path.cwd() build_dir = env.required_path("OUTPUT_DIRECTORY").absolute() @@ -293,13 +296,13 @@ def main(argv: list[str] | None = None) -> int: ws_root / "MODULE.bazel.lock", package_dir / "BUILD", ] - if not is_bazel_build: + if not config.is_bazel_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) + base_arguments = sphinx_arguments(ws_root, package_dir, build_dir, config) - if action == "live_preview": + if config.action == "live_preview": sphinx_autobuild_main( base_arguments + [ @@ -307,27 +310,27 @@ def main(argv: list[str] | None = None) -> int: "--define=skip_rescanning_via_source_code_linker=1", f"--port={args.port}", ] - + watch_arguments() + + watch_arguments(config) ) return 0 - if action == "incremental": + if config.action == "incremental": builder = "html" - elif action in ("check", "build_needs_json"): + elif config.action in ("check", "build_needs_json"): builder = "needs" - elif action == "linkcheck": + elif config.action == "linkcheck": builder = "linkcheck" else: - raise ValueError(f"Unknown action: {action}") + raise ValueError(f"Unknown action: {config.action}") base_arguments.extend(["-b", builder]) 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") + print(f"docs ({config.action}) finished in {end_time - start_time:.1f} seconds") - if is_bazel_build: + if config.is_bazel_build: # The declared output is owned by the action. Do not record an # interactive cache hash or write a warning marker into the workspace. return exit_code diff --git a/src/docs_cli/main_test.py b/src/docs_cli/main_test.py index 04ca0b6fd..10e559078 100644 --- a/src/docs_cli/main_test.py +++ b/src/docs_cli/main_test.py @@ -19,6 +19,7 @@ from src.docs_cli import cli as docs_cli from src.docs_cli.cli import sphinx_arguments +from src.helper_lib.config import DocsCliConfig @pytest.fixture @@ -77,6 +78,9 @@ def test_build_action_selects_sphinx_builder( build_dir = workspace / "component/_build" if action == "build_needs_json": # The sandboxed action uses its declared output, not the package cache. + # BUILD_WORKSPACE_DIRECTORY is available to ``bazel run`` only; leaving + # it unset lets DocsCliConfig identify this as the build environment. + monkeypatch.delenv("BUILD_WORKSPACE_DIRECTORY") monkeypatch.chdir(workspace) monkeypatch.setenv("OUTPUT_DIRECTORY", "outputs/needs") build_dir = workspace / "outputs/needs" @@ -192,10 +196,16 @@ def test_bazel_configuration_resolves_runfiles_and_preserves_repo_relative_edit_ monkeypatch.setenv("EXTERNAL_NEEDS_FILES", '["@vendor//:needs"]') monkeypatch.setenv("GITHUB_REPOSITORY", "owner/repo") monkeypatch.setenv("KNOWN_GOOD_JSON", "baseline.json") + monkeypatch.setenv("ACTION", "incremental") package = workspace / "component" # Act - arguments = sphinx_arguments(workspace, package, package / "_build") + arguments = sphinx_arguments( + workspace, + package, + package / "_build", + DocsCliConfig.from_environment(), + ) # Assert expected_arguments = { @@ -223,9 +233,15 @@ def test_direct_invocation_resolves_metamodel_relative_to_workspace( # 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") + monkeypatch.setenv("ACTION", "incremental") # Act - arguments = sphinx_arguments(workspace, workspace, workspace / "_build") + arguments = sphinx_arguments( + workspace, + workspace, + workspace / "_build", + DocsCliConfig.from_environment(), + ) # Assert # Without Bazel runfiles, the metamodel falls back to the workspace root. diff --git a/src/helper_lib/config.py b/src/helper_lib/config.py new file mode 100644 index 000000000..414365ba4 --- /dev/null +++ b/src/helper_lib/config.py @@ -0,0 +1,122 @@ +# ******************************************************************************* +# 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 enum import Enum +from pathlib import Path + +from python.runfiles import Runfiles + +from src.helper_lib import Environment, find_git_root + + +class ExecutionEnvironment(Enum): + BAZEL_RUN = "bazel_run" + BAZEL_BUILD = "bazel_build" + DIRECT = "direct" + + +class DocsCliConfig: + """Configuration consumed by the documentation launcher. + + Keeping environment parsing in one place lets the launcher operate on a + stable configuration object. Paths stored on this object are resolved to + the filesystem visible to the current process. The logical package and + source paths remain available for repository metadata such as GitHub edit + links. + """ + + def _identify_environment(self) -> ExecutionEnvironment: + """Identify how the current Python process was started.""" + if self.ws_root: + return ExecutionEnvironment.BAZEL_RUN + if self._runfiles: + return ExecutionEnvironment.BAZEL_BUILD + return ExecutionEnvironment.DIRECT + + @property + def is_bazel_build(self): + """Whether this configuration belongs to the sandboxed Needs action.""" + return self.environment == ExecutionEnvironment.BAZEL_BUILD + + @property + def is_bazel_run(self): + """Whether this configuration belongs to a ``bazel run`` target.""" + return self.environment == ExecutionEnvironment.BAZEL_RUN + + @property + def is_direct(self): + """Whether the launcher was started outside Bazel.""" + return self.environment == ExecutionEnvironment.DIRECT + + @classmethod + def from_environment(cls, env: Environment | None = None) -> "DocsCliConfig": + """Load configuration from the process environment or a test mapping.""" + return cls(env if env is not None else Environment()) + + def __init__(self, env: Environment): + """ + Load launcher configuration from the current Bazel environment. + + Specifically, this method handles bazel build and run differences. + """ + + # These three must be queried first: + self.ws_root = env.optional_path("BUILD_WORKSPACE_DIRECTORY") + self._runfiles = Runfiles.Create() + self.environment = self._identify_environment() + + # Then fill the rest as required: + if self.is_bazel_build or self.is_bazel_run: + # git_root exists only in direct mode... and even then its optional! + self.git_root = find_git_root() + + self.action = env.get("ACTION") + + # Sanity checks + if self.ws_root: + self._require_directory(self.ws_root, "BUILD_WORKSPACE_DIRECTORY") + + def _resolve_input_path(self, path: Path) -> Path | None: + """ + Resolve an optional config input in its current execution context. + """ + if self.is_bazel_build or self.is_bazel_run: + # Interactive Bazel targets receive runfiles-relative paths from + # ``rlocationpath``. The runfiles tree is the only stable location + # for generated files and external repository inputs. + assert self._runfiles + loc = self._runfiles.Rlocation(str(path)) + return Path(loc).absolute() if loc else None + else: + # Direct invocations resolve relative inputs from the workspace or + # current working directory. + base = self.ws_root or Path.cwd() + return (base / path).absolute() + + @staticmethod + def _require_directory(path: Path, environment_name: str) -> None: + """Fail while loading config when a required directory is unavailable.""" + if not path.is_dir(): + raise ValueError( + f"Environment variable {environment_name} must name an existing " + f"directory: {path}" + ) + + @staticmethod + def _require_file(path: Path | None, environment_name: str) -> None: + """Fail while loading config when an optional file is configured badly.""" + if path is not None and not path.is_file(): + raise ValueError( + f"Environment variable {environment_name} must name an existing " + f"file: {path}" + )