Skip to content
Draft
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
359 changes: 270 additions & 89 deletions src/docs_cli/cli.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/docs_cli/dirty_build_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def docs_workspace(fs: FFS, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("SOURCE_DIRECTORY", "docs")
monkeypatch.setenv("DATA", "[]")
fs.create_dir(_WORKSPACE / "component")
fs.create_dir(_WORKSPACE / "component/docs")
for name in ("MODULE.bazel", "MODULE.bazel.lock", "component/BUILD"):
fs.create_file(_WORKSPACE / name, contents="stable")
return _WORKSPACE
Expand Down
153 changes: 146 additions & 7 deletions src/docs_cli/main_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
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
from src.docs_cli.cli import DocsCliConfig, sphinx_arguments


@pytest.fixture
Expand All @@ -33,8 +33,10 @@ def workspace(fs: FFS, monkeypatch: pytest.MonkeyPatch) -> Path:
"MOUNTS_MANIFEST",
"SPHINX_CONFIG_FILE",
"SCORE_METAMODEL_YAML",
"SCORE_SOURCELINKS",
"GITHUB_REPOSITORY",
"KNOWN_GOOD_JSON",
"SPHINX_EXTRA_OPTS",
"RUNFILES_DIR",
"RUNFILES_MANIFEST_FILE",
)
Expand All @@ -46,12 +48,28 @@ def workspace(fs: FFS, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setenv("PACKAGE_DIR", "component")
monkeypatch.setenv("SOURCE_DIRECTORY", "docs")
monkeypatch.setenv("DATA", "[]")
monkeypatch.setenv("SPHINX_EXTRA_OPTS", "[]")
monkeypatch.setenv("RUNFILES_DIR", str(workspace / "runfiles"))

fs.create_dir(workspace / "component")
fs.create_dir(workspace / "component/docs")
fs.create_dir(workspace / "docs")
fs.create_dir(workspace / "bundle/docs")
fs.create_dir(workspace / "runfiles")
fs.create_dir(workspace / "runfiles/config")
for name in ("MODULE.bazel", "MODULE.bazel.lock", "component/BUILD"):
fs.create_file(workspace / name, contents="stable")
for name in (
"runfiles/config/conf.py",
"runfiles/config/metamodel.yaml",
"source_links.json",
"baseline.json",
"metamodel.yaml",
"bundle/conf.py",
"bundle/metamodel.yaml",
):
fs.create_file(workspace / name, contents="{}")
fs.create_file(workspace / "runfiles/config/mounts.json", contents='{"mounts": []}')
return workspace


Expand Down Expand Up @@ -93,12 +111,19 @@ def test_build_action_selects_sphinx_builder(
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.
# The source and output paths are derived from the correct execution
# context. The build action uses its execution-root source and declared
# output; interactive actions use the workspace package cache.
if action == "build_needs_json":
assert arguments[:2] == [str(workspace / "docs"), str(build_dir)]
# The declared output contains only the Needs inventory; Sphinx's
# doctrees and warning diagnostics stay outside that output tree.
assert ["-d", str(build_dir) + "_doctrees"] == arguments[10:12]
assert "--warning-file" not in arguments
update_hash.assert_not_called()
else:
assert arguments[:2] == [str(workspace / "component/docs"), str(build_dir)]
assert "--warning-file" in arguments
# The action selects the builder exposed by its public Bazel target.
assert arguments[-2:] == ["-b", builder]

Expand Down Expand Up @@ -186,30 +211,41 @@ def test_bazel_configuration_resolves_runfiles_and_preserves_repo_relative_edit_
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Arrange
monkeypatch.setenv("ACTION", "incremental")
monkeypatch.setenv("SPHINX_CONFIG_FILE", "config/conf.py")
monkeypatch.setenv("SCORE_METAMODEL_YAML", "config/metamodel.yaml")
monkeypatch.setenv("MOUNTS_MANIFEST", "config/mounts.json")
monkeypatch.setenv("SCORE_SOURCELINKS", "source_links.json")
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")
config = DocsCliConfig.from_environment()
assert config.source_directory == workspace / "component/docs"
assert config.sphinx_config_file == workspace / "runfiles/config/conf.py"
assert config.metamodel_yaml == workspace / "runfiles/config/metamodel.yaml"
assert config.mounts_manifest == workspace / "runfiles/config/mounts.json"
assert config.score_sourcelinks_json == workspace / "source_links.json"
assert config.known_good_json == workspace / "baseline.json"
arguments = sphinx_arguments(config)

# Assert
expected_arguments = {
# Generated configuration and metamodel paths use the runfiles tree.
# Runfiles-backed configuration inputs use the runfiles tree.
"-c",
str(workspace / "runfiles/config"),
f"--define=score_metamodel_yaml={workspace}/runfiles/config/metamodel.yaml",
f"--define=mounts_manifest={workspace}/runfiles/config/mounts.json",
f"--define=score_sourcelinks_json={workspace}/source_links.json",
# 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",
f"--define=KNOWN_GOOD_JSON={workspace}/baseline.json",
}
# Every expected option is present; their relative order is irrelevant here.
assert expected_arguments <= set(arguments)
Expand All @@ -221,14 +257,117 @@ def test_direct_invocation_resolves_metamodel_relative_to_workspace(
) -> None:
# Arrange
# This test covers the non-Bazel fallback, so no runfiles directory exists.
monkeypatch.setenv("ACTION", "incremental")
monkeypatch.delenv("BUILD_WORKSPACE_DIRECTORY", raising=False)
monkeypatch.delenv("RUNFILES_DIR", raising=False)
monkeypatch.setenv("SCORE_METAMODEL_YAML", "metamodel.yaml")
monkeypatch.chdir(workspace)

# Act
arguments = sphinx_arguments(workspace, workspace, workspace / "_build")
arguments = sphinx_arguments(DocsCliConfig.from_environment())

# 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


def test_bazel_run_allows_workspace_root_package(
workspace: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The root package is represented by an intentionally empty PACKAGE_DIR."""
# Arrange
monkeypatch.setenv("ACTION", "incremental")
monkeypatch.setenv("PACKAGE_DIR", "")

# Act
config = DocsCliConfig.from_environment()

# Assert
assert config.is_bazel_run
assert config.package_directory == Path()
assert config.package_dir == workspace
assert config.source_directory == workspace / "docs"
assert config.build_dir == workspace / "_build"


def test_bazel_build_configuration_uses_execution_root_paths_and_extra_options(
workspace: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Build actions use declared paths and preserve action-specific options."""
# Arrange
monkeypatch.setenv("ACTION", "build_needs_json")
monkeypatch.setenv("SOURCE_DIRECTORY", "bundle/docs")
monkeypatch.setenv("OUTPUT_DIRECTORY", "outputs/needs")
monkeypatch.setenv("SPHINX_CONFIG_FILE", "bundle/conf.py")
monkeypatch.setenv("SCORE_METAMODEL_YAML", "bundle/metamodel.yaml")
monkeypatch.setenv("SPHINX_EXTRA_OPTS", '["--define=custom=value with spaces"]')
monkeypatch.chdir(workspace)

# Act
config = DocsCliConfig.from_environment()
arguments = sphinx_arguments(config)

# Assert
assert config.is_bazel_build
assert not config.is_bazel_run
assert config.source_directory == workspace / "bundle/docs"
assert config.output_directory == workspace / "outputs/needs"
assert config.sphinx_config_file == workspace / "bundle/conf.py"
assert config.metamodel_yaml == workspace / "bundle/metamodel.yaml"
assert arguments[:2] == [
str(workspace / "bundle/docs"),
str(workspace / "outputs/needs"),
]
assert ["-d", str(workspace / "outputs/needs_doctrees")] == arguments[10:12]
assert "--warning-file" not in arguments
assert "--define=custom=value with spaces" in arguments


@pytest.mark.parametrize(
"environment_name,value",
[
("BUILD_WORKSPACE_DIRECTORY", "/workspace/missing"),
("SOURCE_DIRECTORY", "missing/docs"),
("SPHINX_CONFIG_FILE", "config/missing.py"),
("SCORE_METAMODEL_YAML", "config/missing.yaml"),
("KNOWN_GOOD_JSON", "missing.json"),
("MOUNTS_MANIFEST", "/workspace/missing-mounts.json"),
("SCORE_SOURCELINKS", "missing-source-links.json"),
],
)
def test_configuration_rejects_missing_input_paths_early(
workspace: Path,
monkeypatch: pytest.MonkeyPatch,
environment_name: str,
value: str,
) -> None:
"""Configured workspace and input paths fail before Sphinx is invoked."""
# Arrange
monkeypatch.setenv("ACTION", "incremental")
monkeypatch.setenv(environment_name, value)

# Act and assert
with pytest.raises(ValueError, match=environment_name):
DocsCliConfig.from_environment()


def test_bazel_build_allows_declared_output_to_be_created_by_sphinx(
workspace: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A sandboxed action validates inputs but leaves its output creation to Sphinx."""
# Arrange
monkeypatch.setenv("ACTION", "build_needs_json")
monkeypatch.setenv("OUTPUT_DIRECTORY", "outputs/not-created-yet")
monkeypatch.chdir(workspace)

# Act
config = DocsCliConfig.from_environment()

# Assert
assert config.output_directory == workspace / "outputs/not-created-yet"
assert not config.output_directory.exists()
23 changes: 11 additions & 12 deletions src/extensions/score_cross_module_compatibility/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import html
import json
import os
import re
from dataclasses import asdict, dataclass, replace
from pathlib import Path
Expand Down Expand Up @@ -220,21 +219,21 @@ def write(self, outdir: str | Path) -> None:


def _manifest_path(app: Sphinx) -> Path | None:
raw = getattr(app.config, "mounts_manifest", "") or os.environ.get(
"MOUNTS_MANIFEST", ""
)
raw = getattr(app.config, "mounts_manifest", "")
if not isinstance(raw, str) or not raw.strip():
return None
direct = Path(raw)
runfiles = get_runfiles_dir() / raw
# ``mounts_manifest`` may be an execroot path, while the environment value
# passed to ``bazel run`` is runfiles-relative. Prefer an existing path so
# the policy does not depend on the current working directory.
if direct.is_file():
if direct.is_absolute():
return direct
elif direct.is_file():
# A direct path is already usable from the current Sphinx process.
return direct
elif find_ws_root():
# ``bazel run`` may provide a runfiles-relative path when this
# extension is used without the documentation launcher.
return get_runfiles_dir() / direct
else:
return direct
if runfiles.is_file():
return runfiles
return runfiles if find_ws_root() else direct


def get_reporter(app: Sphinx) -> CompatibilityReporter:
Expand Down
8 changes: 4 additions & 4 deletions src/extensions/score_layout/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import sphinx_options
from sphinx.application import Sphinx

from src.helper_lib import config_setdefault
from src.helper_lib import config_setdefault, find_ws_root

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -118,9 +118,9 @@ def configure_mounted_source_controls(
return

source_path = source_path.resolve()
workspace_directory = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
if workspace_directory:
workspace_root = Path(workspace_directory).resolve()
workspace_root = find_ws_root()
if workspace_root is not None:
workspace_root = workspace_root.resolve()
if (
source_path.is_relative_to(workspace_root)
and not {"bazel-bin", "bazel-out"}.intersection(source_path.parts)
Expand Down
7 changes: 3 additions & 4 deletions src/extensions/score_metamodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
import importlib
import os
import pkgutil
from collections.abc import Callable
from pathlib import Path
Expand All @@ -35,7 +34,7 @@
load_metamodel_data as load_metamodel_data,
validate_mandatory_regexes as validate_mandatory_regexes,
)
from src.helper_lib import config_setdefault
from src.helper_lib import config_setdefault, find_ws_root

logger = logging.get_logger(__name__)

Expand Down Expand Up @@ -110,8 +109,8 @@ def _run_checks(app: Sphinx) -> None:

logger.debug(f"Running checks for {len(needs_all_needs)} needs")

ws_root = os.environ.get("BUILD_WORKSPACE_DIRECTORY", None)
cwd_or_ws_root = Path(ws_root) if ws_root else Path.cwd()
ws_root = find_ws_root()
cwd_or_ws_root = ws_root or Path.cwd()
prefix = str(Path(app.srcdir).relative_to(cwd_or_ws_root))

log = CheckLogger(logger, prefix, get_reporter(app))
Expand Down
8 changes: 6 additions & 2 deletions src/extensions/score_metamodel/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
#
# SPDX-License-Identifier: Apache-2.0
# *******************************************************************************
import os
from typing import Any

from docutils.nodes import Node
Expand All @@ -19,6 +18,8 @@
from sphinx_needs.logging import SphinxLoggerAdapter
from sphinx_needs.need_item import NeedItem

from src.helper_lib import ExecutionEnvironment, identify_environment

Location = str | tuple[str | None, int | None] | Node | None
NewCheck = tuple[str, Location]
logger = logging.get_logger(__name__)
Expand Down Expand Up @@ -47,7 +48,10 @@ def get(key: str) -> Any:
# Note: passing the location as a string allows us to use
# readable relative paths, passing as a tuple results
# in absolute paths to ~/.cache/.../bazel-out/..
if "RUNFILES_DIR" in os.environ or "RUNFILES_MANIFEST_FILE" in os.environ:
if identify_environment() in (
ExecutionEnvironment.BAZEL_RUN,
ExecutionEnvironment.BAZEL_BUILD,
):
matching_file = f"{need['docname']}{need['doctype']}"
else:
matching_file = f"{prefix}/{need['docname']}{need['doctype']}"
Expand Down
Loading
Loading