Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 28 additions & 25 deletions src/docs_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So PACKAGE_DIR is not in the DocsCLIConfig ?
On purpose?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same with output Dir in line 292

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

v0 of config 😝

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()
Expand All @@ -293,41 +296,41 @@ 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
+ [
# Note: bools need to be passed via '0' and '1' from the command line.
"--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
Expand Down
20 changes: 18 additions & 2 deletions src/docs_cli/main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
122 changes: 122 additions & 0 deletions src/helper_lib/config.py
Original file line number Diff line number Diff line change
@@ -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}"
)
Loading