diff --git a/scripts_bazel/BUILD b/scripts_bazel/BUILD index 650893318..6140cc74c 100644 --- a/scripts_bazel/BUILD +++ b/scripts_bazel/BUILD @@ -53,5 +53,7 @@ py_binary( srcs = ["traceability_gate.py"], main = "traceability_gate.py", visibility = ["//visibility:public"], - deps = [], + deps = [ + "//src/helper_lib", + ], ) diff --git a/scripts_bazel/traceability_gate.py b/scripts_bazel/traceability_gate.py index 1f85ee832..9d7cd6d39 100644 --- a/scripts_bazel/traceability_gate.py +++ b/scripts_bazel/traceability_gate.py @@ -29,12 +29,14 @@ import argparse import json -import os import sys from pathlib import Path from typing import Any +from src.helper_lib import Environment + _SUPPORTED_SCHEMA_VERSION = "2" +env = Environment() def _print_type_summary( @@ -259,9 +261,9 @@ def main() -> int: args.fail_on_broken_test_refs = True metrics_path = Path(args.metrics_json) - workspace_dir = os.environ.get("BUILD_WORKSPACE_DIRECTORY", "").strip() + workspace_dir = env.optional_path("BUILD_WORKSPACE_DIRECTORY") if not metrics_path.is_absolute() and workspace_dir: - metrics_path = Path(workspace_dir) / metrics_path + metrics_path = workspace_dir / metrics_path if not metrics_path.exists(): print(f"Error: metrics JSON not found: {metrics_path}", file=sys.stderr) return 1 diff --git a/src/docs_cli/cli.py b/src/docs_cli/cli.py index 1e9fb18d1..56b94475b 100644 --- a/src/docs_cli/cli.py +++ b/src/docs_cli/cli.py @@ -30,20 +30,13 @@ ) from src.extensions.score_mounts._resolver import load_mounts_manifest, resolve_walk_dir -from src.helper_lib import find_ws_root, get_runfiles_dir +from src.helper_lib import Environment, find_ws_root, get_runfiles_dir logger = logging.getLogger(__name__) _MODULE_HASH_FILE = ".module_bazel_hash" - - -def get_env(name: str) -> str: - 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 +env = Environment() def _merged_external_needs() -> str: @@ -52,8 +45,8 @@ def _merged_external_needs() -> str: Both env vars hold JSON lists of Bazel labels; the extension parses the resulting `external_needs_source` define uniformly. """ - data = json.loads(get_env("DATA") or "[]") - external = json.loads(os.environ.get("EXTERNAL_NEEDS_FILES", "[]") or "[]") + data = env.string_list("DATA") + external = env.string_list("EXTERNAL_NEEDS_FILES", "[]") return json.dumps(data + external) @@ -146,8 +139,8 @@ def add_watch_dir(path: Path) -> None: def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[str]: """Resolve package sources and Bazel-provided configuration for every builder.""" - is_bazel_build = os.environ.get("ACTION") == "build_needs_json" - source_directory = get_env("SOURCE_DIRECTORY") + is_bazel_build = env.get("ACTION", "") == "build_needs_json" + source_directory = env.required_path("SOURCE_DIRECTORY") base_arguments = [ str(package_dir / source_directory), str(build_dir), @@ -159,10 +152,10 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ # Merge DATA (:needs_json / :docs_sources) with EXTERNAL_NEEDS_FILES # (:needs_json_file) into one define consumed by the Sphinx extensions. f"--define=external_needs_source={_merged_external_needs()}", - f"--define=testcase_source_dirs={os.environ.get('TEST_SOURCES', '[]')}", + f"--define=testcase_source_dirs={env.get('TEST_SOURCES', '[]')}", # Path to the Bazel-emitted mounts manifest (empty when no mounts are # configured); consumed by the score_mounts extension. - f"--define=mounts_manifest={os.environ.get('MOUNTS_MANIFEST', '')}", + f"--define=mounts_manifest={env.optional_path('MOUNTS_MANIFEST') or ''}", ] if is_bazel_build: @@ -175,7 +168,7 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ # The sandboxed Needs rule transports options as JSON so spaces, quotes and # equals signs survive the environment boundary. Append them last so an # action-specific value can override one of the shared defaults above. - base_arguments.extend(json.loads(os.environ.get("SPHINX_EXTRA_OPTS", "[]"))) + base_arguments.extend(env.string_list("SPHINX_EXTRA_OPTS", "[]")) else: # Interactive builds keep warnings in the workspace so developers can # inspect them after a failed build. A Bazel action reports failure @@ -183,38 +176,35 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ # this diagnostic side file. base_arguments.extend(["--warning-file", str(build_dir / "warnings.txt")]) - generated_config = os.environ.get("SPHINX_CONFIG_FILE", "") - if generated_config: + if config_file := env.optional_path("SPHINX_CONFIG_FILE"): # The action receives ctx.file.config.path, which is interpreted from # the action's execution-root working directory. Resolve it locally # instead of using runfiles lookup; interactive targets receive a # runfiles-relative path and need that lookup before Sphinx gets the # containing directory. - config_file = Path(generated_config) if is_bazel_build: config_file = config_file.absolute() elif not config_file.is_absolute(): config_file = get_runfiles_dir() / config_file base_arguments.extend(["-c", str(config_file.parent)]) - metamodel_yaml = os.environ.get("SCORE_METAMODEL_YAML", "") - if metamodel_yaml: + if metamodel_yaml := env.optional_path("SCORE_METAMODEL_YAML"): # Under ``bazel run``, this environment variable is runfiles-relative # and must be resolved through RUNFILES_DIR. A sandboxed Needs action # 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 os.path.isabs(metamodel_yaml): - runfiles_dir = os.environ.get("RUNFILES_DIR", "") - metamodel_yaml = str( - (Path(runfiles_dir) / metamodel_yaml) - if runfiles_dir - else (ws_root / metamodel_yaml) + if not is_bazel_build and not metamodel_yaml.is_absolute(): + runfiles_dir = env.optional_path("RUNFILES_DIR") + metamodel_yaml = ( + runfiles_dir / metamodel_yaml + if runfiles_dir is not None + else ws_root / metamodel_yaml ) - metamodel_yaml = os.path.abspath(metamodel_yaml) + metamodel_yaml = metamodel_yaml.absolute() base_arguments.append(f"--define=score_metamodel_yaml={metamodel_yaml}") - if github_repository := os.getenv("GITHUB_REPOSITORY"): + if github_repository := env.get("GITHUB_REPOSITORY", ""): # GITHUB_REPOSITORY is expected as "owner/repo"; partition("/") splits # once into (owner, separator, repo), so we can ignore the separator. github_user, _, github_repo = github_repository.partition("/") @@ -224,18 +214,20 @@ def sphinx_arguments(ws_root: Path, package_dir: Path, build_dir: Path) -> list[ base_arguments.append("-A=github_version=main") # doc_path must be repo-relative so the edit URL does not contain the # absolute runner filesystem path (e.g. /home/runner/work/…/docs). - relative_doc_path = Path(os.environ.get("PACKAGE_DIR", "")) / source_directory + relative_doc_path = ( + env.optional_path("PACKAGE_DIR") or Path() + ) / source_directory base_arguments.append(f"-A=doc_path={relative_doc_path}") - if os.getenv("KNOWN_GOOD_JSON"): - base_arguments.append(f"--define=KNOWN_GOOD_JSON={get_env('KNOWN_GOOD_JSON')}") + if known_good_json := env.optional_path("KNOWN_GOOD_JSON"): + base_arguments.append(f"--define=KNOWN_GOOD_JSON={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", "") + mounts_manifest = env.optional_path("MOUNTS_MANIFEST") watch_arguments: list[str] = [] if mounts_manifest: # ``MOUNTS_MANIFEST`` is runfiles-relative under ``bazel run`` and @@ -244,7 +236,7 @@ def watch_arguments() -> list[str]: manifest_path = ( get_runfiles_dir() / mounts_manifest if ws_root is not None - else Path(mounts_manifest) + else mounts_manifest ) for watch_dir in mounted_watch_dirs( manifest_path, @@ -284,17 +276,17 @@ 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 = get_env("ACTION") + action = env.get("ACTION") is_bazel_build = action == "build_needs_json" - ws_root = Path(os.getenv("BUILD_WORKSPACE_DIRECTORY", "")) + ws_root = env.optional_path("BUILD_WORKSPACE_DIRECTORY") 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 / os.environ.get("PACKAGE_DIR", "") + package_dir = ws_root / (env.optional_path("PACKAGE_DIR") or Path()) build_dir = package_dir / "_build" if is_bazel_build: # Bazel owns the action's paths; never use the caller's workspace cache. package_dir = Path.cwd() - build_dir = Path(get_env("OUTPUT_DIRECTORY")).absolute() + build_dir = env.required_path("OUTPUT_DIRECTORY").absolute() sentinel_files = [ ws_root / "MODULE.bazel", @@ -353,7 +345,8 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": # Extensions need stable runfiles paths even when Sphinx changes directory. for variable in ("RUNFILES_DIR", "JAVA_RUNFILES"): - if os.environ.get(variable): - os.environ[variable] = str(Path(os.environ[variable]).absolute()) + value = env.optional_path(variable) + if value: + os.environ[variable] = str(value.absolute()) sys.exit(main()) diff --git a/src/extensions/score_cross_module_compatibility/__init__.py b/src/extensions/score_cross_module_compatibility/__init__.py index 3c408ee51..96c6fb075 100644 --- a/src/extensions/score_cross_module_compatibility/__init__.py +++ b/src/extensions/score_cross_module_compatibility/__init__.py @@ -13,7 +13,6 @@ import html import json -import os import re from dataclasses import asdict, dataclass, replace from pathlib import Path @@ -23,10 +22,11 @@ from sphinx.util import logging from sphinx_needs.need_item import NeedItem -from src.helper_lib import find_ws_root, get_runfiles_dir +from src.helper_lib import Environment, find_ws_root, get_runfiles_dir _VERSION_CONDITION = re.compile(r"^\s*version\s*==\s*(\d+)\s*$") logger = logging.getLogger(__name__) +env = Environment() MANDATORY_ATTRIBUTE = "mandatory-attribute" MANDATORY_LINK = "mandatory-link" @@ -220,9 +220,7 @@ 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", "") or env.get("MOUNTS_MANIFEST", "") if not isinstance(raw, str) or not raw.strip(): return None direct = Path(raw) diff --git a/src/extensions/score_layout/BUILD b/src/extensions/score_layout/BUILD index fcd3be75d..34d1ba3b8 100644 --- a/src/extensions/score_layout/BUILD +++ b/src/extensions/score_layout/BUILD @@ -32,5 +32,8 @@ py_library( # python package if we ever want to do that. imports = ["."], visibility = ["//visibility:public"], - deps = [requirement("sphinx")], + deps = [ + requirement("sphinx"), + "//src/helper_lib", + ], ) diff --git a/src/extensions/score_layout/__init__.py b/src/extensions/score_layout/__init__.py index b51f0c02a..dfd49eb9f 100644 --- a/src/extensions/score_layout/__init__.py +++ b/src/extensions/score_layout/__init__.py @@ -20,9 +20,10 @@ import sphinx_options from sphinx.application import Sphinx -from src.helper_lib import config_setdefault +from src.helper_lib import Environment, config_setdefault logger = logging.getLogger(__name__) +env = Environment() # TEMP UNTIL UPSTREAM FIX - BEGIN # Bug ref: https://github.com/useblocks/sphinx-needs/issues/1913 @@ -118,9 +119,9 @@ def configure_mounted_source_controls( return source_path = source_path.resolve() - workspace_directory = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + workspace_directory = env.optional_path("BUILD_WORKSPACE_DIRECTORY") if workspace_directory: - workspace_root = Path(workspace_directory).resolve() + workspace_root = workspace_directory.resolve() if ( source_path.is_relative_to(workspace_root) and not {"bazel-bin", "bazel-out"}.intersection(source_path.parts) diff --git a/src/extensions/score_metamodel/__init__.py b/src/extensions/score_metamodel/__init__.py index 98c8d19fd..8a6a35777 100644 --- a/src/extensions/score_metamodel/__init__.py +++ b/src/extensions/score_metamodel/__init__.py @@ -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 @@ -35,9 +34,10 @@ 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 Environment, config_setdefault logger = logging.get_logger(__name__) +env = Environment() local_check_function = Callable[[Sphinx, NeedItem, CheckLogger], None] graph_check_function = Callable[[Sphinx, NeedsView, CheckLogger], None] @@ -110,8 +110,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 = env.optional_path("BUILD_WORKSPACE_DIRECTORY") + cwd_or_ws_root = ws_root if ws_root else Path.cwd() prefix = str(Path(app.srcdir).relative_to(cwd_or_ws_root)) log = CheckLogger(logger, prefix, get_reporter(app)) diff --git a/src/extensions/score_metamodel/log.py b/src/extensions/score_metamodel/log.py index ae0088ac2..52bf6a03a 100644 --- a/src/extensions/score_metamodel/log.py +++ b/src/extensions/score_metamodel/log.py @@ -10,7 +10,6 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -import os from typing import Any from docutils.nodes import Node @@ -19,9 +18,12 @@ from sphinx_needs.logging import SphinxLoggerAdapter from sphinx_needs.need_item import NeedItem +from src.helper_lib import Environment + Location = str | tuple[str | None, int | None] | Node | None NewCheck = tuple[str, Location] logger = logging.get_logger(__name__) +env = Environment() class CheckLogger: @@ -47,7 +49,9 @@ 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 env.optional_path("RUNFILES_DIR") or env.optional_path( + "RUNFILES_MANIFEST_FILE" + ): matching_file = f"{need['docname']}{need['doctype']}" else: matching_file = f"{prefix}/{need['docname']}{need['doctype']}" diff --git a/src/extensions/score_mounts/__init__.py b/src/extensions/score_mounts/__init__.py index f00ec2f04..72b1de106 100644 --- a/src/extensions/score_mounts/__init__.py +++ b/src/extensions/score_mounts/__init__.py @@ -33,7 +33,6 @@ from __future__ import annotations -import os from pathlib import Path from sphinx.application import Sphinx @@ -47,7 +46,9 @@ resolve_source_files, resolve_walk_dir, ) -from src.helper_lib import find_ws_root, get_runfiles_dir +from src.helper_lib import Environment, find_ws_root, get_runfiles_dir + +env = Environment() logger = logging.getLogger(__name__) @@ -62,9 +63,7 @@ def _read_manifest(config: Config): it is relative to the exec root (``$(location)``). Resolving the path here keeps that context branch out of the pure ``_resolver`` module. """ - raw = getattr(config, "mounts_manifest", None) or os.environ.get( - "MOUNTS_MANIFEST", None - ) + raw = getattr(config, "mounts_manifest", None) or env.get("MOUNTS_MANIFEST", "") if not raw or not raw.strip() or not isinstance(raw, str): return None diff --git a/src/extensions/score_source_code_linker/BUILD b/src/extensions/score_source_code_linker/BUILD index 98a2c68ea..0dffba0d5 100644 --- a/src/extensions/score_source_code_linker/BUILD +++ b/src/extensions/score_source_code_linker/BUILD @@ -59,6 +59,7 @@ py_library( ], imports = ["."], visibility = ["//visibility:public"], + deps = ["//src/helper_lib"], ) score_pytest( diff --git a/src/extensions/score_source_code_linker/__init__.py b/src/extensions/score_source_code_linker/__init__.py index 2de69f1f4..6cab5c507 100644 --- a/src/extensions/score_source_code_linker/__init__.py +++ b/src/extensions/score_source_code_linker/__init__.py @@ -20,7 +20,6 @@ # req-Id: tool_req__docs_dd_link_source_code_link # This whole directory implements the above mentioned tool requirements -import os from copy import deepcopy from pathlib import Path from typing import Any, cast @@ -60,7 +59,9 @@ construct_and_add_need, run_xml_parser, ) -from src.helper_lib import find_ws_root +from src.helper_lib import Environment, find_ws_root + +env = Environment() LOGGER = get_logger(__name__) # Uncomment this to enable more verbose logging @@ -86,7 +87,7 @@ def build_and_save_combined_file(outdir: Path, app: Sphinx | None = None): Reads the saved partial caches of codelink & testlink Builds the combined JSON cache & saves it """ - source_code_links_path = os.environ.get("SCORE_SOURCELINKS") + source_code_links_path = env.get("SCORE_SOURCELINKS", "") if not source_code_links_path and app is not None: source_code_links_path = str( getattr(app.config, "score_sourcelinks_json", "") or "" diff --git a/src/extensions/score_source_code_linker/needlinks.py b/src/extensions/score_source_code_linker/needlinks.py index 2998240fc..4b8c616d6 100644 --- a/src/extensions/score_source_code_linker/needlinks.py +++ b/src/extensions/score_source_code_linker/needlinks.py @@ -13,11 +13,14 @@ # req-Id: tool_req__docs_dd_link_source_code_link import json -import os from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, TypedDict, TypeGuard +from src.helper_lib import Environment + +env = Environment() + class MetaData(TypedDict): repo_name: str @@ -180,7 +183,7 @@ def load_source_code_links_with_metadata_json(file: Path) -> list[NeedLink]: This normally should be the one called 'locally' => :docs target """ if not file.is_absolute(): - ws_root = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + ws_root = env.get("BUILD_WORKSPACE_DIRECTORY", "") if ws_root: file = Path(ws_root) / file @@ -221,9 +224,9 @@ def load_source_code_links_json(file: Path) -> list[NeedLink]: """ if not file.is_absolute(): # use env variable set by Bazel - ws_root = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + ws_root = env.optional_path("BUILD_WORKSPACE_DIRECTORY") if ws_root: - file = Path(ws_root) / file + file = ws_root / file links: list[NeedLink] = json.loads( file.read_text(encoding="utf-8"), diff --git a/src/extensions/score_source_code_linker/xml_parser.py b/src/extensions/score_source_code_linker/xml_parser.py index 97a5bc044..fde71ee7b 100644 --- a/src/extensions/score_source_code_linker/xml_parser.py +++ b/src/extensions/score_source_code_linker/xml_parser.py @@ -49,7 +49,9 @@ store_data_of_test_case_json, store_test_xml_parsed_json, ) -from src.helper_lib import find_ws_root +from src.helper_lib import Environment, find_ws_root + +env = Environment() logger = logging.get_logger(__name__) logger.setLevel("DEBUG") @@ -144,16 +146,14 @@ def get_metadata_from_test_path(raw_filepath: Path) -> MetaData: Removing everything up to and including 'bazel-testlogs' or 'tests-report' """ # print("THIs IS FILEPATH IN GET MD FROm TestPATH: ", raw_filepath) - known_good_json = os.environ.get("KNOWN_GOOD_JSON") + known_good_json = env.optional_path("KNOWN_GOOD_JSON") clean_filepath = clean_test_file_name(raw_filepath) # print(f"This is the cleaned filepath: {clean_filepath}") repo_name = parse_repo_name_from_path(clean_filepath) md = DefaultMetaData() md["repo_name"] = repo_name if repo_name != "local_repo" and known_good_json: - md["hash"], md["url"] = parse_info_from_known_good( - Path(known_good_json), repo_name - ) + md["hash"], md["url"] = parse_info_from_known_good(known_good_json, repo_name) return md diff --git a/src/helper_lib/__init__.py b/src/helper_lib/__init__.py index 3cd1f7f77..ada4bf9d9 100644 --- a/src/helper_lib/__init__.py +++ b/src/helper_lib/__init__.py @@ -11,7 +11,6 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* -import os import subprocess import sys from enum import Enum @@ -22,6 +21,8 @@ from sphinx.config import Config from sphinx_needs.logging import get_logger +from .env import Environment + LOGGER = get_logger(__name__) @@ -50,8 +51,7 @@ def find_ws_root() -> Path | None: - 'bazel build' => ❌ None (sandbox isolation) - 'direct sphinx' => ❌ None (no Bazel environment) """ - ws_dir = os.environ.get("BUILD_WORKSPACE_DIRECTORY", None) - return Path(ws_dir) if ws_dir else None + return Environment().optional_path("BUILD_WORKSPACE_DIRECTORY") def identify_environment() -> ExecutionEnvironment: @@ -217,9 +217,13 @@ def get_runfiles_dir() -> Path: Find the Bazel runfiles directory using bazel_runfiles convention, fallback to RUNFILES_DIR or relative traversal if needed. """ - if (r := Runfiles.Create()) and (rd := r.EnvVars().get("RUNFILES_DIR")): - runfiles_dir = Path(rd) - else: + runfiles = Runfiles.Create() + runfiles_dir = ( + Environment(runfiles.EnvVars()).optional_path("RUNFILES_DIR") + if runfiles is not None + else None + ) + if runfiles_dir is None: # The only way to land here is when running from within the virtual # environment created by the `:ide_support` rule in the BUILD file. # i.e. esbonio or manual sphinx-build execution within the virtual diff --git a/src/helper_lib/env.py b/src/helper_lib/env.py new file mode 100644 index 000000000..ed3975b9b --- /dev/null +++ b/src/helper_lib/env.py @@ -0,0 +1,82 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +import json +import logging +import os +from collections.abc import Mapping +from pathlib import Path +from typing import cast + +logger = logging.getLogger(__name__) + + +class Environment: + """Typed access to the process environment used by the CLI config loader.""" + + def __init__(self, values: Mapping[str, str] | None = None) -> None: + self._values = os.environ if values is None else values + + def get(self, name: str, default: str | None = None) -> str: + """Read a value, raising when it is missing and no default is supplied.""" + value = self._values.get(name) + logger.debug("Env: %s = %s", name, value) + if value is not None: + # Preserve an explicitly configured value, including an empty string. + return value.strip() + elif default is not None: + # A caller-provided default makes this environment variable optional. + return default + else: + # A missing value without a default is required configuration. + raise ValueError(f"Environment variable {name} is not set") + + def optional_path(self, name: str) -> Path | None: + """ + Read an optional path from the environment. + + Note: this will not verify or modify the exact environment variable's value beyond converting it to a Path. + """ + value = self.get(name, "") + return Path(value) if value else None + + def required_path(self, name: str) -> Path: + """ + Read a required path from the environment. + + Note: this will not verify or modify the exact environment variable's value beyond converting it to a Path. + """ + value = self.get(name, "") + if not value: + raise ValueError(f"Environment variable {name} is not set") + return Path(value) + + def json(self, name: str, default: str | None = None) -> object: + """Read and decode a JSON value from the environment.""" + return json.loads(self.get(name, default)) + + def string_list(self, name: str, default: str | None = None) -> list[str]: + """Read a JSON list and validate that every item is a string.""" + raw_value = self.get(name, default) + # DATA was historically allowed to be present but empty. Treat that as + # an empty list while still requiring the environment variable itself. + if not raw_value: + return [] + value = json.loads(raw_value) + if not isinstance(value, list) or not all( + isinstance(item, str) for item in cast(list[object], value) + ): + raise ValueError( + f"Environment variable {name} must contain a list of strings" + ) + return cast(list[str], value) diff --git a/tools/BUILD b/tools/BUILD index 8736e0af8..93b6741da 100644 --- a/tools/BUILD +++ b/tools/BUILD @@ -28,5 +28,7 @@ py_binary( allow_empty = True, ), main = "module_verification_reports.py", - deps = [], + deps = [ + "//src/helper_lib", + ], ) diff --git a/tools/module_verification_reports.py b/tools/module_verification_reports.py index 61e8652a2..0ad9d83ec 100644 --- a/tools/module_verification_reports.py +++ b/tools/module_verification_reports.py @@ -31,7 +31,6 @@ import fcntl import html import json -import os import re import shutil import subprocess @@ -43,6 +42,10 @@ from typing import cast from urllib.parse import urlparse +from src.helper_lib import Environment + +env = Environment() + GITHUB_ORG = "eclipse-score" TEMPLATE_NAME = "module_verification_report" REPORT_WORKPRODUCT = "wp__verification_module_ver_report" @@ -917,9 +920,8 @@ def build_gallery( def _workspace_root() -> Path: - workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY") - if workspace: - return Path(workspace).resolve() + if workspace := env.optional_path("BUILD_WORKSPACE_DIRECTORY"): + return workspace.resolve() return Path(__file__).resolve().parents[1]