diff --git a/cli/core/__init__.py b/cli/core/__init__.py index 8b13789179..a11dbb1745 100644 --- a/cli/core/__init__.py +++ b/cli/core/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[2] diff --git a/cli/core/app.py b/cli/core/app.py index 44a982d694..ba571b47bb 100644 --- a/cli/core/app.py +++ b/cli/core/app.py @@ -16,6 +16,8 @@ from cli.core.help import ( ) from cli.core.run import RunConfig, open_log_file, run_command_once +from . import PROJECT_ROOT + @dataclass class Flags: @@ -109,7 +111,7 @@ def main() -> None: flags = parse_flags(argv) args = argv[1:] - cli_dir = Path(__file__).resolve().parents[1] + cli_dir = PROJECT_ROOT / "cli" # sanity: cli_dir should contain __init__.py and __main__.py of dispatcher # but we do not hard-fail here diff --git a/cli/deploy/container/__init__.py b/cli/deploy/container/__init__.py index 047f3cc0c4..3aea667c02 100644 --- a/cli/deploy/container/__init__.py +++ b/cli/deploy/container/__init__.py @@ -1,6 +1,10 @@ from __future__ import annotations -from .command import ( +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] + +from .command import ( # noqa: E402 main, remove_container, run_in_container, diff --git a/cli/deploy/container/command.py b/cli/deploy/container/command.py index cf8e39ed39..8e065ddd2a 100644 --- a/cli/deploy/container/command.py +++ b/cli/deploy/container/command.py @@ -7,8 +7,8 @@ import subprocess import sys import time import uuid -from pathlib import Path from typing import List, Tuple +from . import PROJECT_ROOT INFINITO_SRC_DIR = "/opt/src/infinito" @@ -153,7 +153,7 @@ def start_ci_container( name = f"infinito-ci-{uuid.uuid4().hex[:8]}" print(f">>> Starting CI container '{name}' (host Docker socket mode)...") - project_root = str(Path(__file__).resolve().parents[3]) + project_root = str(PROJECT_ROOT) subprocess.run( [ "docker", diff --git a/cli/deploy/development/__init__.py b/cli/deploy/development/__init__.py index a9b50fb880..f7c7e01ea5 100644 --- a/cli/deploy/development/__init__.py +++ b/cli/deploy/development/__init__.py @@ -1,5 +1,9 @@ from __future__ import annotations -from .cli import main +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] + +from .cli import main # noqa: E402 __all__ = ["main"] diff --git a/cli/deploy/development/build.py b/cli/deploy/development/build.py index f82bdfd064..9b23cb4409 100644 --- a/cli/deploy/development/build.py +++ b/cli/deploy/development/build.py @@ -4,7 +4,8 @@ import argparse import os import subprocess -from .common import repo_root_from_here, resolve_distro +from .common import resolve_distro +from . import PROJECT_ROOT def add_parser(sub: argparse._SubParsersAction) -> None: @@ -35,7 +36,7 @@ def add_parser(sub: argparse._SubParsersAction) -> None: def handler(args: argparse.Namespace) -> int: - repo_root = repo_root_from_here() + repo_root = PROJECT_ROOT script = repo_root / "scripts" / "image" / "build.sh" repository_name_script = ( repo_root / "scripts" / "meta" / "resolve" / "repository" / "name.sh" diff --git a/cli/deploy/development/common.py b/cli/deploy/development/common.py index 100adeaa2e..b86d70b3aa 100644 --- a/cli/deploy/development/common.py +++ b/cli/deploy/development/common.py @@ -1,10 +1,10 @@ from __future__ import annotations import os -from pathlib import Path from typing import TYPE_CHECKING from .deps import apps_with_deps, resolve_run_after +from . import PROJECT_ROOT if TYPE_CHECKING: from .compose import Compose @@ -20,10 +20,6 @@ DEV_INVENTORY_VARS_FILE: str = os.environ.get( VALID_DISTROS: tuple[str, ...] = ("arch", "debian", "ubuntu", "fedora", "centos") -def repo_root_from_here() -> Path: - return Path(__file__).resolve().parents[3] - - def compose_file_args() -> list[str]: """Compose `-f` flags shared by up and down flows.""" from .profile import Profile @@ -91,7 +87,7 @@ def make_compose() -> Compose: distro = resolve_distro() # Surface env-script gap here rather than as a cryptic compose error later. resolve_container() - return Compose(repo_root=repo_root_from_here(), distro=distro) + return Compose(repo_root=PROJECT_ROOT, distro=distro) def resolve_deploy_ids_for_app(compose: Compose, app_id: str) -> list[str]: diff --git a/cli/deploy/development/deploy.py b/cli/deploy/development/deploy.py index dffe621046..dbdf25a1b3 100644 --- a/cli/deploy/development/deploy.py +++ b/cli/deploy/development/deploy.py @@ -13,10 +13,10 @@ from cli.create.inventory.services_disabler import ( from .common import ( make_compose, - repo_root_from_here, resolve_container, ) from .inventory import filter_plan_to_variant, plan_dev_inventory_matrix +from . import PROJECT_ROOT def _env_variant() -> int | None: @@ -115,7 +115,7 @@ def _purge_app_entities(*, container: str, app_ids: list[str]) -> None: """ if not app_ids: return - repo_root = repo_root_from_here() + repo_root = PROJECT_ROOT purge_script = ( repo_root / "scripts" / "tests" / "deploy" / "local" / "purge" / "entity.sh" ) diff --git a/cli/deploy/development/down.py b/cli/deploy/development/down.py index a6d9a02a50..ef159053dc 100644 --- a/cli/deploy/development/down.py +++ b/cli/deploy/development/down.py @@ -8,10 +8,7 @@ from pathlib import Path from .common import cache_env_overrides, compose_file_args, resolve_distro from .profile import Profile - - -def _repo_root_from_here() -> Path: - return Path(__file__).resolve().parents[3] +from . import PROJECT_ROOT CI_DOCKER_ROOT = Path("/mnt/docker") @@ -82,5 +79,5 @@ def add_parser(sub: argparse._SubParsersAction) -> None: def handler(args: argparse.Namespace) -> int: - down_stack(repo_root=_repo_root_from_here(), distro=resolve_distro()) + down_stack(repo_root=PROJECT_ROOT, distro=resolve_distro()) return 0 diff --git a/cli/fix/docker_image_versions/__init__.py b/cli/fix/docker_image_versions/__init__.py index 6ca801af38..77ffe32ea2 100644 --- a/cli/fix/docker_image_versions/__init__.py +++ b/cli/fix/docker_image_versions/__init__.py @@ -1 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] + """CLI entry point package for Docker image version updates.""" diff --git a/cli/fix/docker_image_versions/__main__.py b/cli/fix/docker_image_versions/__main__.py index fe7e9a6381..0fa3446a63 100644 --- a/cli/fix/docker_image_versions/__main__.py +++ b/cli/fix/docker_image_versions/__main__.py @@ -5,6 +5,7 @@ import argparse from pathlib import Path from utils.docker.version_updater import apply_updates, find_outdated_updates +from . import PROJECT_ROOT def main() -> int: @@ -14,7 +15,7 @@ def main() -> int: parser.add_argument( "--repo-root", type=Path, - default=Path(__file__).resolve().parents[3], + default=PROJECT_ROOT, help="Repository root to scan and update", ) parser.add_argument( diff --git a/cli/fix/vars_main_files/__init__.py b/cli/fix/vars_main_files/__init__.py index ea05b2e689..a0209fbb9e 100644 --- a/cli/fix/vars_main_files/__init__.py +++ b/cli/fix/vars_main_files/__init__.py @@ -1,6 +1,12 @@ from __future__ import annotations -from . import __main__ as _main +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] + +# `__main__` does `from . import PROJECT_ROOT` at import time, so the +# constant has to be defined first. +from . import __main__ as _main # noqa: E402 # Expose configuration that tests monkeypatch ROLES_DIR = _main.ROLES_DIR diff --git a/cli/fix/vars_main_files/__main__.py b/cli/fix/vars_main_files/__main__.py index aba5a7239d..29de3ed9eb 100644 --- a/cli/fix/vars_main_files/__main__.py +++ b/cli/fix/vars_main_files/__main__.py @@ -11,9 +11,10 @@ import yaml from utils.cache.yaml import dump_yaml, load_yaml_any from pathlib import Path +from . import PROJECT_ROOT + # Directory containing roles; can be overridden by tests -MODULE_DIR = Path(__file__).resolve().parent -ROLES_DIR = (MODULE_DIR.parents[3] / "roles").resolve() +ROLES_DIR = (PROJECT_ROOT / "roles").resolve() def process_role(role_dir: Path, prefix: str, preview: bool, overwrite: bool): diff --git a/cli/make/__init__.py b/cli/make/__init__.py index 16c336b6ba..15078640b9 100644 --- a/cli/make/__init__.py +++ b/cli/make/__init__.py @@ -8,10 +8,13 @@ We re-export the public API so existing imports keep working. from __future__ import annotations -from . import __main__ as _main +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[2] + +# `__main__` does `from . import PROJECT_ROOT` at import time, so the +# constant has to be defined first. +from . import __main__ as _main # noqa: E402 __all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) globals().update({name: getattr(_main, name) for name in __all__}) - -# Prefer explicit __all__ if the original module defined it. -__all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) diff --git a/cli/make/__main__.py b/cli/make/__main__.py index 5a87efeb6c..f9090d935d 100644 --- a/cli/make/__main__.py +++ b/cli/make/__main__.py @@ -25,9 +25,9 @@ def main(): # Default to 'build' if no target is specified make_args = args.targets or ["build"] - # Determine repository root (one level up from cli/) - script_dir = os.path.dirname(os.path.realpath(__file__)) - repo_root = os.path.abspath(os.path.join(script_dir, os.pardir)) + from . import PROJECT_ROOT + + repo_root = str(PROJECT_ROOT) # Check for Makefile makefile_path = os.path.join(repo_root, "Makefile") diff --git a/cli/meta/applications/resolution/combined/__init__.py b/cli/meta/applications/resolution/combined/__init__.py index ca101e94c4..97b70bfb20 100644 --- a/cli/meta/applications/resolution/combined/__init__.py +++ b/cli/meta/applications/resolution/combined/__init__.py @@ -1 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] + # Intentionally empty; executable via: python -m cli.meta.applications.combined_resolution diff --git a/cli/meta/applications/resolution/combined/repo_paths.py b/cli/meta/applications/resolution/combined/repo_paths.py index 6e3eb53f79..05935805d9 100644 --- a/cli/meta/applications/resolution/combined/repo_paths.py +++ b/cli/meta/applications/resolution/combined/repo_paths.py @@ -1,15 +1,11 @@ -# cli/meta/applications/resolution/combined/repo_paths.py from __future__ import annotations from pathlib import Path - - -def repo_root_from_here() -> Path: - return Path(__file__).resolve().parents[5] +from . import PROJECT_ROOT def roles_dir() -> Path: - return repo_root_from_here() / "roles" + return PROJECT_ROOT / "roles" def role_dir(role_name: str) -> Path: diff --git a/cli/meta/applications/resolution/dependencies/__init__.py b/cli/meta/applications/resolution/dependencies/__init__.py index b04124341c..68765becfd 100644 --- a/cli/meta/applications/resolution/dependencies/__init__.py +++ b/cli/meta/applications/resolution/dependencies/__init__.py @@ -1 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] + # Intentionally empty; module is executable via -m. diff --git a/cli/meta/applications/resolution/dependencies/__main__.py b/cli/meta/applications/resolution/dependencies/__main__.py index 17a10e86fc..97a765269e 100644 --- a/cli/meta/applications/resolution/dependencies/__main__.py +++ b/cli/meta/applications/resolution/dependencies/__main__.py @@ -18,18 +18,15 @@ from pathlib import Path from typing import Dict, List, Set from utils.cache.yaml import load_yaml +from . import PROJECT_ROOT class DependenciesResolutionError(RuntimeError): """Raised when dependencies resolution fails (e.g., cycle detected).""" -def repo_root_from_here() -> Path: - return Path(__file__).resolve().parents[5] - - def roles_dir() -> Path: - return repo_root_from_here() / "roles" + return PROJECT_ROOT / "roles" def role_meta_path(role_name: str) -> Path: diff --git a/cli/meta/applications/resolution/run_after/__init__.py b/cli/meta/applications/resolution/run_after/__init__.py index 8b13789179..8ee16d7567 100644 --- a/cli/meta/applications/resolution/run_after/__init__.py +++ b/cli/meta/applications/resolution/run_after/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/cli/meta/applications/resolution/run_after/__main__.py b/cli/meta/applications/resolution/run_after/__main__.py index 1556503aae..738aa388f5 100644 --- a/cli/meta/applications/resolution/run_after/__main__.py +++ b/cli/meta/applications/resolution/run_after/__main__.py @@ -25,17 +25,15 @@ from utils.roles.meta_lookup import ( get_role_run_after, ) +from . import PROJECT_ROOT + class RunAfterResolutionError(RuntimeError): """Raised when run_after resolution fails (e.g., cycle detected).""" -def repo_root_from_here() -> Path: - return Path(__file__).resolve().parents[5] - - def roles_dir() -> Path: - return repo_root_from_here() / "roles" + return PROJECT_ROOT / "roles" def load_run_after(role_name: str) -> List[str]: diff --git a/cli/meta/applications/resolution/services/__init__.py b/cli/meta/applications/resolution/services/__init__.py index fb3622a661..b72a7b96b0 100644 --- a/cli/meta/applications/resolution/services/__init__.py +++ b/cli/meta/applications/resolution/services/__init__.py @@ -1 +1,5 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] + # Intentionally empty; importable package. diff --git a/cli/meta/applications/resolution/services/resolver.py b/cli/meta/applications/resolution/services/resolver.py index 14f8041145..28eb5898e2 100644 --- a/cli/meta/applications/resolution/services/resolver.py +++ b/cli/meta/applications/resolution/services/resolver.py @@ -12,7 +12,9 @@ from utils.service_registry import ( resolve_service_dependency_roles_from_config, ) -_ROLES_ROOT = Path(__file__).parents[5] / "roles" +from . import PROJECT_ROOT + +_ROLES_ROOT = PROJECT_ROOT / "roles" def _stable_dedup(items: Iterable[str]) -> List[str]: diff --git a/cli/meta/applications/role_name/__init__.py b/cli/meta/applications/role_name/__init__.py index d496b34489..cc5a536b6e 100644 --- a/cli/meta/applications/role_name/__init__.py +++ b/cli/meta/applications/role_name/__init__.py @@ -8,10 +8,13 @@ We re-export the public API so existing imports keep working. from __future__ import annotations -from . import __main__ as _main +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] + +# `__main__` does `from . import PROJECT_ROOT` at import time, so the +# constant has to be defined first. +from . import __main__ as _main # noqa: E402 __all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) globals().update({name: getattr(_main, name) for name in __all__}) - -# Prefer explicit __all__ if the original module defined it. -__all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) diff --git a/cli/meta/applications/role_name/__main__.py b/cli/meta/applications/role_name/__main__.py index c6219254c0..157348a6f8 100644 --- a/cli/meta/applications/role_name/__main__.py +++ b/cli/meta/applications/role_name/__main__.py @@ -14,6 +14,8 @@ import argparse from utils.cache.yaml import load_yaml +from . import PROJECT_ROOT + def get_role(application_id, roles_path): """ @@ -55,13 +57,7 @@ def main(): parser.add_argument( "-r", "--roles-path", - default=os.path.join( - os.path.dirname(os.path.realpath(__file__)), - os.pardir, - os.pardir, - os.pardir, - "roles", - ), + default=str(PROJECT_ROOT / "roles"), help="Path to the roles directory (default: roles/ at project root)", ) diff --git a/cli/meta/callorder/__init__.py b/cli/meta/callorder/__init__.py index 8b13789179..78e3cae448 100644 --- a/cli/meta/callorder/__init__.py +++ b/cli/meta/callorder/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/cli/meta/callorder/__main__.py b/cli/meta/callorder/__main__.py index 8de8096cab..fd21b072b2 100644 --- a/cli/meta/callorder/__main__.py +++ b/cli/meta/callorder/__main__.py @@ -35,25 +35,13 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from utils.cache.yaml import load_yaml_any as _load_yaml_cached +from . import PROJECT_ROOT def eprint(msg: str) -> None: print(msg, file=sys.stderr) -def repo_root_from_this_script() -> Path: - """ - Compute repo root *only* from this script's absolute location. - - Expected layout: - /cli/meta/callorder/__main__.py - - Therefore: - repo_root = Path(__file__).resolve().parents[3] - """ - return Path(__file__).resolve().parents[3] - - def list_group_files(groups_dir: Path) -> List[Path]: """ Return group YAML files under tasks/groups (skip .gitignore and non-yml). @@ -169,7 +157,7 @@ def main(argv: Optional[List[str]] = None) -> int: args = ap.parse_args(argv) - repo_root = repo_root_from_this_script() + repo_root = PROJECT_ROOT groups = build_groups(repo_root) global_order = flatten_callorder(groups) diff --git a/cli/meta/categories/invokable/__init__.py b/cli/meta/categories/invokable/__init__.py index 681b460135..1798e533ab 100644 --- a/cli/meta/categories/invokable/__init__.py +++ b/cli/meta/categories/invokable/__init__.py @@ -1,5 +1,9 @@ from __future__ import annotations -from .command import main +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] + +from .command import main # noqa: E402 __all__ = ["main"] diff --git a/cli/meta/categories/invokable/command.py b/cli/meta/categories/invokable/command.py index eeec1c1781..9a0180cd92 100755 --- a/cli/meta/categories/invokable/command.py +++ b/cli/meta/categories/invokable/command.py @@ -12,7 +12,6 @@ from __future__ import annotations import argparse import sys -from pathlib import Path import yaml @@ -24,13 +23,7 @@ from plugins.filter.invokable_paths import ( get_non_invokable_paths, ) - -def _project_root_from_here() -> Path: - """ - Determine repo root by fixed parent depth. - command.py -> invokable -> categories -> meta -> cli -> repo_root - """ - return Path(__file__).resolve().parents[4] +from . import PROJECT_ROOT def main() -> None: @@ -69,7 +62,7 @@ def main() -> None: args = parser.parse_args() # Ensure repo root is importable (CLI use outside repo root) - repo_root = _project_root_from_here() + repo_root = PROJECT_ROOT sys.path.insert(0, str(repo_root)) try: diff --git a/cli/meta/runtime/__init__.py b/cli/meta/runtime/__init__.py index 749482ef40..ac62e8f4bd 100644 --- a/cli/meta/runtime/__init__.py +++ b/cli/meta/runtime/__init__.py @@ -2,19 +2,7 @@ from __future__ import annotations import os from pathlib import Path - - -def get_project_root() -> Path: - """ - Return the project root based on the location of this file. - - Assumes this file lives in: - /cli/meta/runtime/__init__.py - - Therefore: - project-root = parents[3] - """ - return Path(__file__).resolve().parents[3] +from . import PROJECT_ROOT def detect_runtime(project_root: Path | None = None) -> str: @@ -51,7 +39,7 @@ def detect_runtime(project_root: Path | None = None) -> str: return "github" # 4) local dev compose stack (project-root relative marker) - root = project_root or get_project_root() + root = project_root or PROJECT_ROOT if (root / "env.development").exists(): return "dev" diff --git a/cli/meta/runtime/__main__.py b/cli/meta/runtime/__main__.py index 6c2ec2ba11..8fb402da84 100644 --- a/cli/meta/runtime/__main__.py +++ b/cli/meta/runtime/__main__.py @@ -3,7 +3,7 @@ from __future__ import annotations import argparse import json -from . import detect_runtime, get_project_root +from . import PROJECT_ROOT, detect_runtime def _build_parser() -> argparse.ArgumentParser: @@ -31,7 +31,7 @@ def main() -> int: parser = _build_parser() args = parser.parse_args() - root = get_project_root() + root = PROJECT_ROOT runtime = detect_runtime(project_root=root) if args.json: diff --git a/cli/validate/inventory/__init__.py b/cli/validate/inventory/__init__.py index 4604aecd2d..2057ede148 100644 --- a/cli/validate/inventory/__init__.py +++ b/cli/validate/inventory/__init__.py @@ -8,10 +8,13 @@ We re-export the public API so existing imports keep working. from __future__ import annotations -from . import __main__ as _main +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] + +# `__main__` does `from . import PROJECT_ROOT` at import time, so the +# constant has to be defined first. +from . import __main__ as _main # noqa: E402 __all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) globals().update({name: getattr(_main, name) for name in __all__}) - -# Prefer explicit __all__ if the original module defined it. -__all__ = getattr(_main, "__all__", [n for n in dir(_main) if not n.startswith("_")]) diff --git a/cli/validate/inventory/__main__.py b/cli/validate/inventory/__main__.py index 0ef31e8b0a..f058aff7cd 100644 --- a/cli/validate/inventory/__main__.py +++ b/cli/validate/inventory/__main__.py @@ -4,10 +4,15 @@ import re import sys from pathlib import Path -# Ensure imports work when invoked as a script (subprocess without PYTHONPATH). -repo_root = Path(__file__).resolve().parents[3] -if str(repo_root) not in sys.path: - sys.path.insert(0, str(repo_root)) +# This script is also invoked as a standalone subprocess +# (`python __main__.py …`), in which case ``from . import PROJECT_ROOT`` +# would fail with ``attempted relative import with no known parent +# package``. Compute the repo root locally and prepend it to sys.path +# before any project-package import resolves. +# nocheck: project-root-import sys.path bootstrap before package imports resolve +PROJECT_ROOT = Path(__file__).resolve().parents[3] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) from utils.cache.applications import get_application_defaults # noqa: E402 from utils.cache.files import read_text # noqa: E402 @@ -121,7 +126,7 @@ def main(): p.add_argument("inventory_dir") p.add_argument( "--roles-dir", - default=str(repo_root / "roles"), + default=str(PROJECT_ROOT / "roles"), help="Path to the repository roles directory.", ) args = p.parse_args() diff --git a/plugins/filter/__init__.py b/plugins/filter/__init__.py index 8b13789179..a11dbb1745 100644 --- a/plugins/filter/__init__.py +++ b/plugins/filter/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[2] diff --git a/plugins/filter/value_generator.py b/plugins/filter/value_generator.py index c1ebf2172f..b273c9f205 100644 --- a/plugins/filter/value_generator.py +++ b/plugins/filter/value_generator.py @@ -3,8 +3,8 @@ from __future__ import annotations from functools import lru_cache -from pathlib import Path import sys +from pathlib import Path from typing import Type @@ -17,6 +17,7 @@ def _value_generator_cls() -> Type[object]: - utils/manager/__init__.py exists (package import works) - plugins/filter/ is located at repo root """ + # nocheck: project-root-import ansible loads filter plugins outside the project package, so relative imports don't resolve repo_root = Path(__file__).resolve().parents[2] utils_path = repo_root / "utils" diff --git a/roles/sys-svc-compose-ca/files/compose_ca.py b/roles/sys-svc-compose-ca/files/compose_ca.py index 51ba748060..be75b7783b 100644 --- a/roles/sys-svc-compose-ca/files/compose_ca.py +++ b/roles/sys-svc-compose-ca/files/compose_ca.py @@ -376,8 +376,9 @@ def _discover_profiles_from_files(compose_files: List[Path]) -> List[str]: die(f"Compose file does not exist: {f}") try: doc = ( - yaml.safe_load(f.read_text(encoding="utf-8")) or {} - ) # nocheck: direct-yaml + yaml.safe_load(f.read_text(encoding="utf-8")) # nocheck: direct-yaml + or {} + ) except Exception as e: die(f"Failed to parse compose file '{f}': {e}") diff --git a/roles/test-e2e-playwright/filter_plugins/discover_playwright_roles.py b/roles/test-e2e-playwright/filter_plugins/discover_playwright_roles.py index 31f96dca7c..c322f97a3d 100644 --- a/roles/test-e2e-playwright/filter_plugins/discover_playwright_roles.py +++ b/roles/test-e2e-playwright/filter_plugins/discover_playwright_roles.py @@ -39,6 +39,7 @@ def discover_playwright_roles( # Current marker for Playwright-enabled app roles: # .../roles//templates/playwright.env.j2 for env_file in base.rglob("templates/playwright.env.j2"): + # nocheck: project-root-import walking from a discovered glob match (/templates/...) up to its role dir, not the repo root role_name = env_file.parents[1].name found.append(role_name) diff --git a/roles/web-app-bluesky/tasks/01_core.yml b/roles/web-app-bluesky/tasks/01_core.yml index eb72c7ec58..573ff58977 100644 --- a/roles/web-app-bluesky/tasks/01_core.yml +++ b/roles/web-app-bluesky/tasks/01_core.yml @@ -6,19 +6,19 @@ # handlers, otherwise `compose build` fires against a missing build context. # Pre-create the services directory and clone the repo here, before any # compose-touching include. -- name: "Ensure social-app services directory exists" - ansible.builtin.file: - path: "{{ BLUESKY_SOCIAL_APP_DIR | dirname }}" - state: directory - mode: "0755" - when: BLUESKY_WEB_ENABLED | bool +- name: "Pre-compose web-enabled staging (services dir + social-app + login-broker)" + block: + - name: "Ensure social-app services directory exists" + ansible.builtin.file: + path: "{{ BLUESKY_SOCIAL_APP_DIR | dirname }}" + state: directory + mode: "0755" -- name: "Clone social-app build context (pre-compose)" - ansible.builtin.include_tasks: "03_social_app.yml" - when: BLUESKY_WEB_ENABLED | bool + - name: "Clone social-app build context (pre-compose)" + ansible.builtin.include_tasks: "03_social_app.yml" -- name: "Stage login-broker build context (pre-compose)" - ansible.builtin.include_tasks: "06_login_broker.yml" + - name: "Stage login-broker build context (pre-compose)" + ansible.builtin.include_tasks: "06_login_broker.yml" when: BLUESKY_WEB_ENABLED | bool # Bind-mount safety: if a previous role-iteration ran without diff --git a/roles/web-app-minio/tasks/02_oidc.yml b/roles/web-app-minio/tasks/02_oidc.yml new file mode 100644 index 0000000000..6222ff873d --- /dev/null +++ b/roles/web-app-minio/tasks/02_oidc.yml @@ -0,0 +1,170 @@ +--- +# mc URL-embedded credentials (`MC_HOST_=https://USER:PASS@host`) +# are NOT URL-decoded by mc when read from the environment, while the +# MinIO server stores the URL-decoded password. With administrator +# credentials containing $ / € / ! / ' the two sides disagree and every +# admin call fails with "request signature we calculated does not +# match". Authenticate via `mc alias set` with explicit args instead; +# mc applies the credentials verbatim, matching the server. + +# MinIO finishes booting its IAM subsystem (where policies and the +# OIDC IdP live) AFTER `mc admin info` already reports the server +# as up. Probing IAM directly via `mc admin policy ls` is the only +# readiness signal that matches what the policy admin API needs; +# `mc admin info` returns rc=0 in a window where the very next +# `mc admin policy info` still fails with `Server not initialized +# yet, please try again.`. +- name: "Wait for MinIO IAM subsystem to finish initializing" + environment: + MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" + MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" + shell: | + set -euo pipefail + container run --rm \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + --entrypoint /bin/sh \ + {{ MINIO_MC_IMAGE }} \ + -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ + && mc admin policy ls minio' + args: { executable: /bin/bash } + register: mc_admin_policy_ls + retries: 60 + delay: 5 + until: mc_admin_policy_ls.rc == 0 + changed_when: false + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + +# Register the OIDC provider under the explicit name `keycloak` +# via `mc idp openid add`. The Console's login page only renders +# the SSO button for NAMED providers (its +# `redirectRules` field is built from named entries only); the +# legacy `MINIO_IDENTITY_OPENID_*` env-var form registers the +# provider as nameless `(default)` and the SSO button never +# appears, even when the server-side IdP is fully functional. +- name: "Register Keycloak as named OIDC IdP on MinIO" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + # All `mc idp openid add` parameters are passed to the inner sh + # via env vars rather than inline-quoted args. The display name + # contains parentheses (e.g. `SSO Login (INFINITO.EXAMPLE)`) and + # any inline shell-quoting at this nesting depth (Ansible shell + # → container run -c → inner sh) lets bash interpret `(` as a + # command-grouping start. Env-passthrough side-steps that + # entirely. + environment: + MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" + MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" + MINIO_OIDC_CONFIG_URL: "{{ OIDC.CLIENT.DISCOVERY_DOCUMENT }}" + MINIO_OIDC_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" + MINIO_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}" + MINIO_OIDC_DISPLAY_NAME: "{{ OIDC.BUTTON_TEXT }}" + MINIO_OIDC_CLAIM_NAME: "{{ RBAC.GROUP.CLAIM }}" + shell: | + set -euo pipefail + container run --rm \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + -e MINIO_OIDC_CONFIG_URL \ + -e MINIO_OIDC_CLIENT_ID \ + -e MINIO_OIDC_CLIENT_SECRET \ + -e MINIO_OIDC_DISPLAY_NAME \ + -e MINIO_OIDC_CLAIM_NAME \ + --entrypoint /bin/sh \ + {{ MINIO_MC_IMAGE }} \ + -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ + && mc idp openid add minio keycloak \ + "config_url=$MINIO_OIDC_CONFIG_URL" \ + "client_id=$MINIO_OIDC_CLIENT_ID" \ + "client_secret=$MINIO_OIDC_CLIENT_SECRET" \ + "display_name=$MINIO_OIDC_DISPLAY_NAME" \ + "claim_name=$MINIO_OIDC_CLAIM_NAME" \ + scopes=openid,profile,email,groups \ + redirect_uri_dynamic=on' + args: { executable: /bin/bash } + register: mc_idp_add + changed_when: '"Successfully" in (mc_idp_add.stdout | default("")) or "Successfully" in (mc_idp_add.stderr | default(""))' + failed_when: + - mc_idp_add.rc != 0 + - '"already exists" not in (mc_idp_add.stderr | default(""))' + +- name: "Restart MinIO so the named OIDC IdP is picked up by the Console" + environment: + MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" + MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" + shell: | + set -euo pipefail + container run --rm \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + --entrypoint /bin/sh \ + {{ MINIO_MC_IMAGE }} \ + -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ + && mc admin service restart minio --json' + args: { executable: /bin/bash } + register: mc_admin_service_restart + changed_when: mc_admin_service_restart.rc == 0 + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + when: mc_idp_add.changed | bool + +- name: "Wait for MinIO IAM subsystem to come back after restart" + environment: + MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" + MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" + shell: | + set -euo pipefail + container run --rm \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + --entrypoint /bin/sh \ + {{ MINIO_MC_IMAGE }} \ + -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ + && mc admin policy ls minio' + args: { executable: /bin/bash } + register: mc_admin_policy_ls_after_restart + retries: 60 + delay: 5 + until: mc_admin_policy_ls_after_restart.rc == 0 + changed_when: false + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + when: mc_idp_add.changed | bool + +- name: "Check policy (RAW with slash) exists" + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + environment: + MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" + MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" + shell: | + set -euo pipefail + container run --rm \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + --entrypoint /bin/sh \ + {{ MINIO_MC_IMAGE }} \ + -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ + && mc admin policy info minio {{ MINIO_OIDC_POLICY_NAME | quote }}' + args: { executable: /bin/bash } + register: mc_policy_info_raw + failed_when: false + changed_when: false + +- name: "Create policy (RAW with slash) if missing" + environment: + MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" + MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" + shell: | + set -euo pipefail + printf '%s' '{{ (MINIO_OIDC_POLICY_CONTENT | from_yaml | to_json) | b64encode }}' \ + | base64 -d \ + | container run --rm -i \ + -e MINIO_ROOT_USER \ + -e MINIO_ROOT_PASSWORD \ + --entrypoint /bin/sh \ + {{ MINIO_MC_IMAGE }} \ + -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ + && mc admin policy create minio {{ MINIO_OIDC_POLICY_NAME | quote }} /dev/stdin' + args: { executable: /bin/bash } + no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" + async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" + poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" + when: + - mc_policy_info_raw.rc != 0 diff --git a/roles/web-app-minio/tasks/main.yml b/roles/web-app-minio/tasks/main.yml index 0d940b3a05..0d13cb2fd3 100644 --- a/roles/web-app-minio/tasks/main.yml +++ b/roles/web-app-minio/tasks/main.yml @@ -17,177 +17,8 @@ loop_var: minio_proxy label: "{{ minio_proxy.domain }} -> {{ minio_proxy.http_port }}" -- block: - # mc URL-embedded credentials (`MC_HOST_=https://USER:PASS@host`) - # are NOT URL-decoded by mc when read from the environment, while the - # MinIO server stores the URL-decoded password. With administrator - # credentials containing $ / € / ! / ' the two sides disagree and every - # admin call fails with "request signature we calculated does not - # match". Authenticate via `mc alias set` with explicit args instead; - # mc applies the credentials verbatim, matching the server. - - # MinIO finishes booting its IAM subsystem (where policies and the - # OIDC IdP live) AFTER `mc admin info` already reports the server - # as up. Probing IAM directly via `mc admin policy ls` is the only - # readiness signal that matches what the policy admin API needs; - # `mc admin info` returns rc=0 in a window where the very next - # `mc admin policy info` still fails with `Server not initialized - # yet, please try again.`. - - name: "Wait for MinIO IAM subsystem to finish initializing" - environment: - MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" - MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" - shell: | - set -euo pipefail - container run --rm \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - --entrypoint /bin/sh \ - {{ MINIO_MC_IMAGE }} \ - -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ - && mc admin policy ls minio' - args: { executable: /bin/bash } - register: mc_admin_policy_ls - retries: 60 - delay: 5 - until: mc_admin_policy_ls.rc == 0 - changed_when: false - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - - # Register the OIDC provider under the explicit name `keycloak` - # via `mc idp openid add`. The Console's login page only renders - # the SSO button for NAMED providers (its - # `redirectRules` field is built from named entries only); the - # legacy `MINIO_IDENTITY_OPENID_*` env-var form registers the - # provider as nameless `(default)` and the SSO button never - # appears, even when the server-side IdP is fully functional. - - name: "Register Keycloak as named OIDC IdP on MinIO" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - # All `mc idp openid add` parameters are passed to the inner sh - # via env vars rather than inline-quoted args. The display name - # contains parentheses (e.g. `SSO Login (INFINITO.EXAMPLE)`) and - # any inline shell-quoting at this nesting depth (Ansible shell - # → container run -c → inner sh) lets bash interpret `(` as a - # command-grouping start. Env-passthrough side-steps that - # entirely. - environment: - MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" - MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" - MINIO_OIDC_CONFIG_URL: "{{ OIDC.CLIENT.DISCOVERY_DOCUMENT }}" - MINIO_OIDC_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" - MINIO_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}" - MINIO_OIDC_DISPLAY_NAME: "{{ OIDC.BUTTON_TEXT }}" - MINIO_OIDC_CLAIM_NAME: "{{ RBAC.GROUP.CLAIM }}" - shell: | - set -euo pipefail - container run --rm \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - -e MINIO_OIDC_CONFIG_URL \ - -e MINIO_OIDC_CLIENT_ID \ - -e MINIO_OIDC_CLIENT_SECRET \ - -e MINIO_OIDC_DISPLAY_NAME \ - -e MINIO_OIDC_CLAIM_NAME \ - --entrypoint /bin/sh \ - {{ MINIO_MC_IMAGE }} \ - -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ - && mc idp openid add minio keycloak \ - "config_url=$MINIO_OIDC_CONFIG_URL" \ - "client_id=$MINIO_OIDC_CLIENT_ID" \ - "client_secret=$MINIO_OIDC_CLIENT_SECRET" \ - "display_name=$MINIO_OIDC_DISPLAY_NAME" \ - "claim_name=$MINIO_OIDC_CLAIM_NAME" \ - scopes=openid,profile,email,groups \ - redirect_uri_dynamic=on' - args: { executable: /bin/bash } - register: mc_idp_add - changed_when: '"Successfully" in (mc_idp_add.stdout | default("")) or "Successfully" in (mc_idp_add.stderr | default(""))' - failed_when: - - mc_idp_add.rc != 0 - - '"already exists" not in (mc_idp_add.stderr | default(""))' - - - name: "Restart MinIO so the named OIDC IdP is picked up by the Console" - environment: - MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" - MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" - shell: | - set -euo pipefail - container run --rm \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - --entrypoint /bin/sh \ - {{ MINIO_MC_IMAGE }} \ - -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ - && mc admin service restart minio --json' - args: { executable: /bin/bash } - register: mc_admin_service_restart - changed_when: mc_admin_service_restart.rc == 0 - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - when: mc_idp_add.changed | bool - - - name: "Wait for MinIO IAM subsystem to come back after restart" - environment: - MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" - MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" - shell: | - set -euo pipefail - container run --rm \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - --entrypoint /bin/sh \ - {{ MINIO_MC_IMAGE }} \ - -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ - && mc admin policy ls minio' - args: { executable: /bin/bash } - register: mc_admin_policy_ls_after_restart - retries: 60 - delay: 5 - until: mc_admin_policy_ls_after_restart.rc == 0 - changed_when: false - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - when: mc_idp_add.changed | bool - - - name: "Check policy (RAW with slash) exists" - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - environment: - MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" - MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" - shell: | - set -euo pipefail - container run --rm \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - --entrypoint /bin/sh \ - {{ MINIO_MC_IMAGE }} \ - -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ - && mc admin policy info minio {{ MINIO_OIDC_POLICY_NAME | quote }}' - args: { executable: /bin/bash } - register: mc_policy_info_raw - failed_when: false - changed_when: false - - - name: "Create policy (RAW with slash) if missing" - environment: - MINIO_ROOT_USER: "{{ lookup('users', 'administrator').username }}" - MINIO_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" - shell: | - set -euo pipefail - printf '%s' '{{ (MINIO_OIDC_POLICY_CONTENT | from_yaml | to_json) | b64encode }}' \ - | base64 -d \ - | container run --rm -i \ - -e MINIO_ROOT_USER \ - -e MINIO_ROOT_PASSWORD \ - --entrypoint /bin/sh \ - {{ MINIO_MC_IMAGE }} \ - -c 'mc alias set minio {{ MINIO_API_URL | quote }} "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null \ - && mc admin policy create minio {{ MINIO_OIDC_POLICY_NAME | quote }} /dev/stdin' - args: { executable: /bin/bash } - no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" - async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" - poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" - when: - - mc_policy_info_raw.rc != 0 - +- name: "Configure MinIO OIDC (Keycloak as named IdP + policy)" + include_tasks: 02_oidc.yml when: MINIO_OIDC_ENABLED | bool # When LDAP is the active auth backend (variant 1), MinIO STS rejects diff --git a/scripts/lint/sort_claude_settings.sh b/scripts/lint/sort_claude_settings.sh index 66dc509e62..0d7620f556 100755 --- a/scripts/lint/sort_claude_settings.sh +++ b/scripts/lint/sort_claude_settings.sh @@ -8,4 +8,8 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" -exec python3 "${REPO_ROOT}/utils/claude/settings_sort.py" "$@" +# Invoke as a module (`python -m`) so the script's `from . import +# PROJECT_ROOT` resolves against the real package, not as a standalone +# script with no parent. +cd -- "${REPO_ROOT}" +exec python3 -m utils.claude.settings_sort "$@" diff --git a/tests/external/docker/__init__.py b/tests/external/docker/__init__.py index 8b13789179..78e3cae448 100644 --- a/tests/external/docker/__init__.py +++ b/tests/external/docker/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/external/docker/test_image_versions.py b/tests/external/docker/test_image_versions.py index 6a6ce18291..18a7d57c63 100644 --- a/tests/external/docker/test_image_versions.py +++ b/tests/external/docker/test_image_versions.py @@ -28,7 +28,6 @@ but any non-comment line resets the search): from __future__ import annotations import unittest -from pathlib import Path from utils.annotations.message import warning from utils.docker.version_updater import ( @@ -46,7 +45,9 @@ from utils.docker.version_updater import ( from utils.docker.image.discovery import iter_role_images -_REPO_ROOT = Path(__file__).resolve().parents[3] +from . import PROJECT_ROOT + +_REPO_ROOT = PROJECT_ROOT _ROLES_ROOT = _REPO_ROOT / "roles" diff --git a/tests/external/repository/__init__.py b/tests/external/repository/__init__.py index 8b13789179..78e3cae448 100644 --- a/tests/external/repository/__init__.py +++ b/tests/external/repository/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/external/repository/test_urls_reachable.py b/tests/external/repository/test_urls_reachable.py index ae6d45e045..362c88355d 100644 --- a/tests/external/repository/test_urls_reachable.py +++ b/tests/external/repository/test_urls_reachable.py @@ -33,8 +33,9 @@ import requests from utils.annotations.message import error, warning from utils.annotations.suppress import is_suppressed_at +from . import PROJECT_ROOT -_REPO_ROOT = Path(__file__).resolve().parents[3] +_REPO_ROOT = PROJECT_ROOT _URL_RE = re.compile(r"https?://[^\s<>'\"`\]]+") _TEMPLATE_MARKERS = ("${", "{{", "}}", "{%", "%}") _PUBLIC_HOST_RE = re.compile(r"^[A-Za-z0-9.-]+$") diff --git a/tests/integration/action_plugins/__init__.py b/tests/integration/action_plugins/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/action_plugins/__init__.py +++ b/tests/integration/action_plugins/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/action_plugins/test_get_url_retry.py b/tests/integration/action_plugins/test_get_url_retry.py index 988926cbb9..9a01aab4b4 100644 --- a/tests/integration/action_plugins/test_get_url_retry.py +++ b/tests/integration/action_plugins/test_get_url_retry.py @@ -6,6 +6,7 @@ import threading import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from . import PROJECT_ROOT class TestGetUrlRetryActionPluginIntegration(unittest.TestCase): @@ -43,7 +44,7 @@ class TestGetUrlRetryActionPluginIntegration(unittest.TestCase): return server, thread, counters, base_url def _run_get_url_retry_playbook_with_default_and_override_blocks(self): - repo_root = Path(__file__).resolve().parents[3] + repo_root = PROJECT_ROOT server = None thread = None diff --git a/tests/integration/action_plugins/test_uri_retry.py b/tests/integration/action_plugins/test_uri_retry.py index a2fb47f6fb..194ee43b65 100644 --- a/tests/integration/action_plugins/test_uri_retry.py +++ b/tests/integration/action_plugins/test_uri_retry.py @@ -6,6 +6,7 @@ import threading import unittest from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from . import PROJECT_ROOT class TestUriRetryActionPluginIntegration(unittest.TestCase): @@ -43,7 +44,7 @@ class TestUriRetryActionPluginIntegration(unittest.TestCase): return server, thread, counters, base_url def _run_uri_retry_playbook_with_default_and_override_blocks(self): - repo_root = Path(__file__).resolve().parents[3] + repo_root = PROJECT_ROOT server = None thread = None diff --git a/tests/integration/compose/test_build_requires_image.py b/tests/integration/compose/test_build_requires_image.py index e2d3f7cc3d..58a97d2358 100644 --- a/tests/integration/compose/test_build_requires_image.py +++ b/tests/integration/compose/test_build_requires_image.py @@ -2,6 +2,7 @@ import re import unittest from pathlib import Path +from . import PROJECT_ROOT BUILD_LOOKUP_RE = re.compile( r"""\{\{\s*lookup\(\s*['"]template['"]\s*,\s*['"]roles/sys-svc-container/templates/build\.yml\.j2['"]\s*\)\s*\|\s*indent\(\s*4\s*\)\s*\}\}""" @@ -29,10 +30,6 @@ class TestComposeBuildTemplateRequiresImageTag(unittest.TestCase): - a YAML merge key `<<:` on the same indentation level (since it may inject `image:`). """ - @staticmethod - def _repo_root() -> Path: - return Path(__file__).resolve().parents[3] - @staticmethod def _iter_compose_templates(repo_root: Path): roles_dir = repo_root / "roles" @@ -81,7 +78,7 @@ class TestComposeBuildTemplateRequiresImageTag(unittest.TestCase): return None def test_build_lookup_requires_image_or_merge_on_same_level(self): - repo_root = self._repo_root() + repo_root = PROJECT_ROOT compose_files = self._iter_compose_templates(repo_root) self.assertTrue( diff --git a/tests/integration/compose/test_templates_require_image.py b/tests/integration/compose/test_templates_require_image.py index 32b2510d5e..2167c41aa8 100644 --- a/tests/integration/compose/test_templates_require_image.py +++ b/tests/integration/compose/test_templates_require_image.py @@ -20,6 +20,8 @@ from dataclasses import dataclass from pathlib import Path from typing import List, Tuple +from . import PROJECT_ROOT + BUILD_RE = re.compile(r"^(?P[ \t]*)build:\s*(#.*)?$") IMAGE_RE = re.compile(r"^(?P[ \t]*)image:\s*(?P.+)?$") @@ -35,16 +37,6 @@ class Finding: note: str -def _repo_root_from_test_file(test_file: str) -> Path: - """ - Compute repo root from this test file location. - tests/integration// -> repo root is parents[3] - """ - p = Path(test_file).resolve() - # .../tests/integration//test_*.py - return p.parents[3] - - def _is_ignored_line(line: str) -> bool: return bool(BLANK_RE.match(line) or COMMENTED_RE.match(line)) @@ -156,8 +148,7 @@ def _scan_file_for_missing_image(path: Path) -> List[Finding]: class TestComposeBuildRequiresImage(unittest.TestCase): def test_all_compose_templates_and_files_have_image_for_build(self) -> None: - repo_root = _repo_root_from_test_file(__file__) - roles_dir = repo_root / "roles" + roles_dir = PROJECT_ROOT / "roles" patterns = [ "*/templates/compose.yml.j2", diff --git a/tests/integration/config/__init__.py b/tests/integration/config/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/config/__init__.py +++ b/tests/integration/config/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/config/test_csp_consistency.py b/tests/integration/config/test_csp_consistency.py index 56d5f01181..a60b794afe 100644 --- a/tests/integration/config/test_csp_consistency.py +++ b/tests/integration/config/test_csp_consistency.py @@ -1,7 +1,7 @@ import unittest import yaml -from pathlib import Path from urllib.parse import urlparse +from . import PROJECT_ROOT class TestCspConfigurationConsistency(unittest.TestCase): @@ -59,7 +59,7 @@ class TestCspConfigurationConsistency(unittest.TestCase): return parsed.scheme in ("http", "https", "ws", "wss") and bool(parsed.netloc) def test_csp_configuration_structure(self): - roles_dir = Path(__file__).resolve().parents[3] / "roles" + roles_dir = PROJECT_ROOT / "roles" errors = [] for role_path in sorted(roles_dir.iterdir()): diff --git a/tests/integration/config/test_non_empty.py b/tests/integration/config/test_non_empty.py index c8e7278ba6..dd58840ff9 100644 --- a/tests/integration/config/test_non_empty.py +++ b/tests/integration/config/test_non_empty.py @@ -3,6 +3,8 @@ import glob import yaml import unittest +from . import PROJECT_ROOT + def find_none_values(data, prefix=None): """ @@ -38,13 +40,7 @@ class TestConfigurationNoNone(unittest.TestCase): # volumes.yml). The legacy roles/*/config/main.yml file no longer # exists. Recurse into every meta/*.yml file and assert no key # resolves to a YAML null. - roles_root = os.path.join( - os.path.dirname(__file__), - os.pardir, - os.pardir, - os.pardir, - "roles", - ) + roles_root = str(PROJECT_ROOT / "roles") pattern = os.path.join(roles_root, "*", "meta", "*.yml") files = glob.glob(pattern) self.assertTrue( diff --git a/tests/integration/docker/__init__.py b/tests/integration/docker/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/docker/__init__.py +++ b/tests/integration/docker/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/docker/test_compose_templates.py b/tests/integration/docker/test_compose_templates.py index 750fd83416..8e143112f0 100644 --- a/tests/integration/docker/test_compose_templates.py +++ b/tests/integration/docker/test_compose_templates.py @@ -1,11 +1,11 @@ import re import unittest -from pathlib import Path + +from . import PROJECT_ROOT class TestDockerComposeTemplates(unittest.TestCase): # Search for all roles/*/templates/compose.yml.j2 - PROJECT_ROOT = Path(__file__).resolve().parents[3] TEMPLATE_PATTERN = "roles/*/templates/compose.yml.j2" # Allowed lines before BASE_INCLUDE @@ -28,7 +28,7 @@ class TestDockerComposeTemplates(unittest.TestCase): 3. BASE_INCLUDE appears before NET_INCLUDE when both are required 4. Only allowed lines appear before BASE_INCLUDE (invalid lines issue warnings) """ - template_paths = sorted(self.PROJECT_ROOT.glob(self.TEMPLATE_PATTERN)) + template_paths = sorted(PROJECT_ROOT.glob(self.TEMPLATE_PATTERN)) self.assertTrue( template_paths, f"No templates found for pattern {self.TEMPLATE_PATTERN}" ) diff --git a/tests/integration/docker/test_services_image_version_valid.py b/tests/integration/docker/test_services_image_version_valid.py index bf03d74840..96306fff5a 100644 --- a/tests/integration/docker/test_services_image_version_valid.py +++ b/tests/integration/docker/test_services_image_version_valid.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Iterable, List, Tuple import yaml from utils.docker.image.ref import is_valid_image_name +from . import PROJECT_ROOT # Docker tag (version) TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$") @@ -65,7 +66,7 @@ class TestDockerServicesImageVersionValid(unittest.TestCase): - `version` is OPTIONAL → only validated if present - No coupling required between image/version """ - repo_root = Path(__file__).resolve().parents[3] + repo_root = PROJECT_ROOT failures: List[str] = [] config_files = _iter_role_config_files(repo_root) diff --git a/tests/integration/domains/__init__.py b/tests/integration/domains/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/domains/__init__.py +++ b/tests/integration/domains/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/domains/test_uniqueness.py b/tests/integration/domains/test_uniqueness.py index 8a3c79502c..f6599b7612 100644 --- a/tests/integration/domains/test_uniqueness.py +++ b/tests/integration/domains/test_uniqueness.py @@ -1,8 +1,8 @@ import unittest -from pathlib import Path from collections import defaultdict from utils.cache.applications import get_application_defaults +from . import PROJECT_ROOT class TestDomainUniqueness(unittest.TestCase): @@ -12,7 +12,7 @@ class TestDomainUniqueness(unittest.TestCase): collect all entries under domains.canonical and domains.aliases across all applications, and assert that no domain appears more than once. """ - repo_root = Path(__file__).resolve().parents[3] + repo_root = PROJECT_ROOT apps = get_application_defaults(roles_dir=repo_root / "roles") domain_to_apps = defaultdict(set) diff --git a/tests/integration/filters/__init__.py b/tests/integration/filters/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/filters/__init__.py +++ b/tests/integration/filters/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/filters/test_strong_password.py b/tests/integration/filters/test_strong_password.py index ad757579eb..2428b5c073 100644 --- a/tests/integration/filters/test_strong_password.py +++ b/tests/integration/filters/test_strong_password.py @@ -4,12 +4,13 @@ import subprocess import tempfile import unittest from pathlib import Path +from . import PROJECT_ROOT class TestStrongPasswordFilterPathIntegration(unittest.TestCase): @unittest.skipUnless(shutil.which("ansible"), "ansible not found") def test_strong_password_filter_uses_repo_relative_utils_path(self): - repo_root = Path(__file__).resolve().parents[3] + repo_root = PROJECT_ROOT source_plugin = repo_root / "plugins" / "filter" / "value_generator.py" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/integration/group_vars/__init__.py b/tests/integration/group_vars/__init__.py index 8b13789179..78e3cae448 100644 --- a/tests/integration/group_vars/__init__.py +++ b/tests/integration/group_vars/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/group_vars/test_no_jinja_recursion.py b/tests/integration/group_vars/test_no_jinja_recursion.py index fa180b375a..d0e1b6eef5 100644 --- a/tests/integration/group_vars/test_no_jinja_recursion.py +++ b/tests/integration/group_vars/test_no_jinja_recursion.py @@ -1,11 +1,11 @@ import re import unittest import yaml -from pathlib import Path from collections import defaultdict +from . import PROJECT_ROOT # Directory containing group_vars/all/*.yml -GROUPVARS_DIR = Path(__file__).resolve().parents[3] / "group_vars" / "all" +GROUPVARS_DIR = PROJECT_ROOT / "group_vars" / "all" JINJA_RE = re.compile(r"{{\s*([^}]+)\s*}}") # Matches variables like foo.bar, foo["bar"], foo['bar'] VAR_PATTERN = re.compile( diff --git a/tests/integration/jinja/__init__.py b/tests/integration/jinja/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/jinja/__init__.py +++ b/tests/integration/jinja/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/jinja/test_includes.py b/tests/integration/jinja/test_includes.py index 2a16f31771..56590c8416 100644 --- a/tests/integration/jinja/test_includes.py +++ b/tests/integration/jinja/test_includes.py @@ -1,6 +1,7 @@ import re import unittest -from pathlib import Path + +from . import PROJECT_ROOT class TestJinjaIncludePaths(unittest.TestCase): @@ -11,12 +12,11 @@ class TestJinjaIncludePaths(unittest.TestCase): - Includes are resolved relative to project root, template directory, or file's parent. """ - PROJECT_ROOT = Path(__file__).resolve().parents[3] INCLUDE_STMT_RE = re.compile(r"{%\s*include\s+(.+?)\s*%}") LITERAL_PATH_RE = re.compile(r"^[\'\"]([^\'\"]+)[\'\"]$") def test_all_jinja_includes_exist(self): - template_paths = list(self.PROJECT_ROOT.glob("**/*.j2")) + template_paths = list(PROJECT_ROOT.glob("**/*.j2")) self.assertTrue( template_paths, "No .j2 templates found anywhere in the project" ) @@ -32,7 +32,7 @@ class TestJinjaIncludePaths(unittest.TestCase): include_path = m.group(1) # check absolute project-relative path - abs_target = self.PROJECT_ROOT / include_path + abs_target = PROJECT_ROOT / include_path # check path relative to the template file rel_target = tpl.parent / include_path @@ -51,10 +51,10 @@ class TestJinjaIncludePaths(unittest.TestCase): or rel_target.exists() or (role_target and role_target.exists()) ): - rel_tpl = tpl.relative_to(self.PROJECT_ROOT) + rel_tpl = tpl.relative_to(PROJECT_ROOT) missing.append( f"{rel_tpl}: included file '{include_path}' not found " - f"(checked in project root, {tpl.parent.relative_to(self.PROJECT_ROOT)} or templates folder)" + f"(checked in project root, {tpl.parent.relative_to(PROJECT_ROOT)} or templates folder)" ) if missing: diff --git a/tests/integration/lookups/config/__init__.py b/tests/integration/lookups/config/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/integration/lookups/config/__init__.py +++ b/tests/integration/lookups/config/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/integration/lookups/config/_scan.py b/tests/integration/lookups/config/_scan.py index 6575828d5b..8981486b0d 100644 --- a/tests/integration/lookups/config/_scan.py +++ b/tests/integration/lookups/config/_scan.py @@ -31,6 +31,7 @@ from utils.cache.applications import get_application_defaults from utils.cache.files import iter_project_files_with_content from utils.cache.users import get_user_defaults from utils.cache.yaml import load_yaml_any +from . import PROJECT_ROOT # Rule key consumed by every `tests/integration/lookups/config/test_*.py`. @@ -261,7 +262,7 @@ _SCANNED_EXTENSIONS: Tuple[str, ...] = (".yml", ".yaml", ".j2") @functools.lru_cache(maxsize=1) def get_context() -> ScanContext: """Return the cached :class:`ScanContext` for the current repo.""" - root = Path(__file__).resolve().parents[4] + root = PROJECT_ROOT roles_root = root / "roles" application_defaults = get_application_defaults(roles_dir=roles_root) user_defaults = get_user_defaults(roles_dir=roles_root) diff --git a/tests/integration/passwords/__init__.py b/tests/integration/passwords/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/passwords/__init__.py +++ b/tests/integration/passwords/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/passwords/test_env_quoted.py b/tests/integration/passwords/test_env_quoted.py index fe6e52e311..ee3f55c30f 100644 --- a/tests/integration/passwords/test_env_quoted.py +++ b/tests/integration/passwords/test_env_quoted.py @@ -25,9 +25,8 @@ from __future__ import annotations import re import unittest -from pathlib import Path from typing import List - +from . import PROJECT_ROOT PASSWORD_ASSIGN_RE = re.compile( r"""^ @@ -50,11 +49,6 @@ JINJA_DOUBLE_WRAPPED_RE = re.compile(r'^"\{\{\s*(?P.+?)\s*\}\}"$') DOTENV_FILTER = "dotenv_quote" -def _repo_root_from_test_file() -> Path: - # tests/integration//test_*.py -> repo root is three levels up - return Path(__file__).resolve().parents[3] - - def _is_full_line_comment(line: str) -> bool: return line.lstrip().startswith("#") @@ -66,7 +60,7 @@ def _is_jinja_control_line(line: str) -> bool: class TestEnvPasswordsQuotedAndFiltered(unittest.TestCase): def test_password_env_vars_are_dotenv_quoted_without_double_quoting(self): - root = _repo_root_from_test_file() + root = PROJECT_ROOT env_templates = sorted(root.rglob("env.j2")) failures: List[str] = [] diff --git a/tests/integration/passwords/test_no_quote_in_definitions.py b/tests/integration/passwords/test_no_quote_in_definitions.py index cab752b938..d4c3e80bc8 100644 --- a/tests/integration/passwords/test_no_quote_in_definitions.py +++ b/tests/integration/passwords/test_no_quote_in_definitions.py @@ -6,6 +6,7 @@ import unittest from dataclasses import dataclass from pathlib import Path from typing import Iterable, List +from . import PROJECT_ROOT QUOTE_FILTER_RE = re.compile(r"\|\s*quote\b", re.IGNORECASE) @@ -63,9 +64,7 @@ def _scan_file(path: Path) -> List[Finding]: class TestPasswordDefinitionsNoQuote(unittest.TestCase): def test_password_definitions_must_not_use_quote_filter(self) -> None: - repo_root = ( - Path(__file__).resolve().parents[3] - ) # tests/integration// -> repo root + repo_root = PROJECT_ROOT # tests/integration// -> repo root all_findings: List[Finding] = [] for yml in _iter_roles_yml_files(repo_root): diff --git a/tests/integration/passwords/test_quote_in_shell.py b/tests/integration/passwords/test_quote_in_shell.py index 2ea65a59c0..977616b9ed 100644 --- a/tests/integration/passwords/test_quote_in_shell.py +++ b/tests/integration/passwords/test_quote_in_shell.py @@ -6,6 +6,7 @@ import unittest from dataclasses import dataclass from pathlib import Path from typing import Iterable, List +from . import PROJECT_ROOT JINJA_EXPR_RE = re.compile(r"{{(.*?)}}", re.DOTALL) @@ -141,9 +142,7 @@ def _scan_shell_block(file_path: Path, start_line: int, block: str) -> List[Find class TestPasswordQuoteInShellTasks(unittest.TestCase): def test_passwords_are_quoted_in_shell_tasks(self) -> None: - repo_root = ( - Path(__file__).resolve().parents[3] - ) # tests/integration// -> repo root + repo_root = PROJECT_ROOT # tests/integration// -> repo root all_findings: List[Finding] = [] for yml in _iter_roles_yml_files(repo_root): diff --git a/tests/integration/playbooks/__init__.py b/tests/integration/playbooks/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/playbooks/__init__.py +++ b/tests/integration/playbooks/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/playbooks/test_invokable_roles_wiring.py b/tests/integration/playbooks/test_invokable_roles_wiring.py index 024de668b8..776b985976 100644 --- a/tests/integration/playbooks/test_invokable_roles_wiring.py +++ b/tests/integration/playbooks/test_invokable_roles_wiring.py @@ -1,11 +1,11 @@ import unittest -from pathlib import Path import re import sys from plugins.filter.invokable_paths import get_invokable_paths +from . import PROJECT_ROOT # Ensure project root is on PYTHONPATH so plugins.filter.* can be imported -ROOT = Path(__file__).parents[3] +ROOT = PROJECT_ROOT sys.path.insert(0, str(ROOT)) STAGES_DIR = ROOT / "tasks" / "stages" diff --git a/tests/integration/roles/__init__.py b/tests/integration/roles/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/roles/__init__.py +++ b/tests/integration/roles/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/roles/applications/__init__.py b/tests/integration/roles/applications/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/integration/roles/applications/__init__.py +++ b/tests/integration/roles/applications/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/integration/roles/applications/id/__init__.py b/tests/integration/roles/applications/id/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/integration/roles/applications/id/__init__.py +++ b/tests/integration/roles/applications/id/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/integration/roles/applications/id/test_consistency.py b/tests/integration/roles/applications/id/test_consistency.py index b99c8d9c6b..e70996fd4e 100644 --- a/tests/integration/roles/applications/id/test_consistency.py +++ b/tests/integration/roles/applications/id/test_consistency.py @@ -1,9 +1,9 @@ import yaml import unittest -from pathlib import Path from plugins.filter.invokable_paths import get_invokable_paths +from . import PROJECT_ROOT -ROLES_DIR = Path(__file__).resolve().parents[5] / "roles" +ROLES_DIR = PROJECT_ROOT / "roles" class TestApplicationIdConsistency(unittest.TestCase): diff --git a/tests/integration/roles/applications/id/test_run_after.py b/tests/integration/roles/applications/id/test_run_after.py index 0644d62faf..1b4dc5b473 100644 --- a/tests/integration/roles/applications/id/test_run_after.py +++ b/tests/integration/roles/applications/id/test_run_after.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 import unittest import yaml -from pathlib import Path +from . import PROJECT_ROOT class TestRunAfterRoles(unittest.TestCase): def setUp(self): - self.roles_dir = Path(__file__).resolve().parents[5] / "roles" + self.roles_dir = PROJECT_ROOT / "roles" self.valid_role_names = {p.name for p in self.roles_dir.iterdir() if p.is_dir()} def test_run_after_roles_are_valid(self): diff --git a/tests/integration/roles/applications/test_group_names.py b/tests/integration/roles/applications/test_group_names.py index 3640d5cdc0..f79e6e134b 100644 --- a/tests/integration/roles/applications/test_group_names.py +++ b/tests/integration/roles/applications/test_group_names.py @@ -1,12 +1,10 @@ -import os import sys import re import unittest +from . import PROJECT_ROOT # ensure project root is on PYTHONPATH so we can import your CLI code -ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir, os.pardir) -) +ROOT = str(PROJECT_ROOT) sys.path.insert(0, ROOT) from cli.meta.applications.all import find_application_ids # noqa: E402 diff --git a/tests/integration/roles/applications/test_valid_usage.py b/tests/integration/roles/applications/test_valid_usage.py index 76d64b6b8b..02387e49f5 100644 --- a/tests/integration/roles/applications/test_valid_usage.py +++ b/tests/integration/roles/applications/test_valid_usage.py @@ -1,12 +1,10 @@ -import os import re import sys import unittest +from . import PROJECT_ROOT # ensure project root is on PYTHONPATH so we can import the CLI code -ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, os.pardir, os.pardir) -) +ROOT = str(PROJECT_ROOT) sys.path.insert(0, ROOT) from cli.meta.applications.all import find_application_ids # noqa: E402 diff --git a/tests/integration/roles/includes/__init__.py b/tests/integration/roles/includes/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/integration/roles/includes/__init__.py +++ b/tests/integration/roles/includes/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/integration/roles/includes/test_existence.py b/tests/integration/roles/includes/test_existence.py index fe454b201b..803d49f3a7 100644 --- a/tests/integration/roles/includes/test_existence.py +++ b/tests/integration/roles/includes/test_existence.py @@ -5,6 +5,7 @@ import yaml import re from utils.cache.files import read_text +from . import PROJECT_ROOT class TestIncludeImportExistence(unittest.TestCase): @@ -16,10 +17,7 @@ class TestIncludeImportExistence(unittest.TestCase): """ def setUp(self): - tests_dir = os.path.dirname(__file__) - self.project_root = os.path.abspath( - os.path.join(tests_dir, os.pardir, os.pardir, os.pardir, os.pardir) - ) + self.project_root = str(PROJECT_ROOT) self.roles_dir = os.path.join(self.project_root, "roles") self.files_to_scan = [] diff --git a/tests/integration/roles/meta/dependencies/__init__.py b/tests/integration/roles/meta/dependencies/__init__.py index e69de29bb2..8ee16d7567 100644 --- a/tests/integration/roles/meta/dependencies/__init__.py +++ b/tests/integration/roles/meta/dependencies/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/integration/roles/meta/dependencies/test_meta_resolve.py b/tests/integration/roles/meta/dependencies/test_meta_resolve.py index bdde0e962a..3fb7cbf550 100644 --- a/tests/integration/roles/meta/dependencies/test_meta_resolve.py +++ b/tests/integration/roles/meta/dependencies/test_meta_resolve.py @@ -3,18 +3,12 @@ import os import glob from utils.cache.yaml import load_yaml_any +from . import PROJECT_ROOT class TestRoleDependencies(unittest.TestCase): def test_dependencies_exist(self): - # Determine the path to the roles directory relative to this test file - tests_dir = os.path.dirname(__file__) - project_root = os.path.abspath( - os.path.join( - tests_dir, os.pardir, os.pardir, os.pardir, os.pardir, os.pardir - ) - ) - roles_dir = os.path.join(project_root, "roles") + roles_dir = os.path.join(str(PROJECT_ROOT), "roles") # Find all meta/main.yml files under roles/*/meta/main.yml pattern = os.path.join(roles_dir, "*", "meta", "main.yml") diff --git a/tests/integration/roles/test_naming.py b/tests/integration/roles/test_naming.py index 7df24f2a59..5111493680 100644 --- a/tests/integration/roles/test_naming.py +++ b/tests/integration/roles/test_naming.py @@ -1,6 +1,6 @@ import unittest import re -from pathlib import Path +from . import PROJECT_ROOT # Regex: # - one or more lowercase letters, digits or hyphens @@ -10,7 +10,7 @@ ROLE_NAME_PATTERN = re.compile(r"^[a-z0-9-]+(?:_[a-z0-9-]+)?$") class TestRoleNames(unittest.TestCase): def test_role_names_follow_naming_convention(self): - roles_dir = Path(__file__).resolve().parents[3] / "roles" + roles_dir = PROJECT_ROOT / "roles" self.assertTrue( roles_dir.is_dir(), f"'roles/' directory not found at {roles_dir}" ) diff --git a/tests/integration/roles/when/__init__.py b/tests/integration/roles/when/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/integration/roles/when/__init__.py +++ b/tests/integration/roles/when/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/integration/roles/when/test_block_size.py b/tests/integration/roles/when/test_block_size.py index 7e3527b5e3..e58c7be434 100644 --- a/tests/integration/roles/when/test_block_size.py +++ b/tests/integration/roles/when/test_block_size.py @@ -3,6 +3,8 @@ from pathlib import Path import yaml from typing import Any, Dict, List, Tuple, Union +from . import PROJECT_ROOT + # -------- YAML loader that's tolerant of Ansible-specific tags (e.g. !vault) ----- class AnsibleTolerantLoader(yaml.SafeLoader): @@ -25,22 +27,6 @@ yaml.add_multi_constructor("!", _ansible_tag_passthrough, Loader=AnsibleTolerant # ------------------------------------------------------------------------------- -def _repo_root() -> Path: - """Find a plausible project root by walking upward from this file.""" - here = Path(__file__).resolve() - cur = here.parent - markers = {".git", "ansible.cfg", "roles", "playbook.yml"} - for _ in range(7): - if any((cur / m).exists() for m in markers): - return cur - parent = cur.parent - if parent == cur: - break - cur = parent - # Fallback: project/tests/integration/roles/when/ -> pick parent of 'tests' - return here.parents[4] if len(here.parents) >= 5 else here.parent - - Yaml = Union[Dict[str, Any], List[Any], Any] @@ -103,7 +89,7 @@ class BlockWhenSizeTest(unittest.TestCase): MAX_TASKS = 3 # performance threshold def test_blocks_with_when_and_sections_have_max_three_tasks(self): - root = _repo_root() + root = PROJECT_ROOT violations: List[str] = [] for yml in _iter_yaml_files(root): diff --git a/tests/integration/roles/when/test_duplication.py b/tests/integration/roles/when/test_duplication.py index 3a57140071..d8f23023b9 100644 --- a/tests/integration/roles/when/test_duplication.py +++ b/tests/integration/roles/when/test_duplication.py @@ -1,6 +1,7 @@ import unittest from pathlib import Path from typing import Any, Dict, Iterable, List, Tuple +from . import PROJECT_ROOT try: import yaml # PyYAML @@ -13,24 +14,6 @@ except ImportError as e: THRESHOLD = 3 # fail if the same when-condition occurs on more than this many tasks -def _find_repo_root_containing(marker_names: Iterable[str], max_up: int = 8) -> Path: - """ - Walk upwards from this file to find the repo root. We assume the project root - contains at least one of `marker_names` (e.g., 'roles', '.git', 'playbooks'). - """ - here = Path(__file__).resolve().parent - cur = here - for _ in range(max_up): - for marker in marker_names: - if (cur / marker).exists(): - return cur - if cur.parent == cur: - break - cur = cur.parent - # Fallback: repo root assumed 4 levels up from tests/integration/roles/when - return Path(__file__).resolve().parents[4] - - def _normalize_when(value: Any) -> str: """ Normalize Ansible 'when' to a comparable string: @@ -150,9 +133,7 @@ class WhenConditionDuplicationTest(unittest.TestCase): """ def test_excessive_repeated_when_in_tasks_files(self): - repo_root = _find_repo_root_containing( - marker_names=(".git", "roles", "playbooks") - ) + repo_root = PROJECT_ROOT tasks_globs = [ "**/tasks/**/*.yml", "**/tasks/**/*.yaml", diff --git a/tests/integration/services/__init__.py b/tests/integration/services/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/services/__init__.py +++ b/tests/integration/services/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/services/test_canonical.py b/tests/integration/services/test_canonical.py index 6f324a56a3..3e62063f52 100644 --- a/tests/integration/services/test_canonical.py +++ b/tests/integration/services/test_canonical.py @@ -1,15 +1,15 @@ import unittest from collections import defaultdict -from pathlib import Path from utils.service_registry import build_service_registry_from_roles_dir +from . import PROJECT_ROOT class TestServicesCanonical(unittest.TestCase): """Discovered canonical aliases from role-local configs must be internally consistent.""" def setUp(self): - self.repo_root = Path(__file__).resolve().parents[3] + self.repo_root = PROJECT_ROOT self.service_registry = build_service_registry_from_roles_dir( self.repo_root / "roles" ) diff --git a/tests/integration/services/test_frontend_spot.py b/tests/integration/services/test_frontend_spot.py index 1c054ab757..bdeb68b75d 100644 --- a/tests/integration/services/test_frontend_spot.py +++ b/tests/integration/services/test_frontend_spot.py @@ -7,10 +7,12 @@ from utils.service_registry import ( ordered_primary_service_entries, ) +from . import PROJECT_ROOT + class TestFrontendServiceSpot(unittest.TestCase): def setUp(self): - self.repo_root = Path(__file__).resolve().parents[3] + self.repo_root = PROJECT_ROOT self.roles_dir = self.repo_root / "roles" self.applications = load_applications_from_roles_dir(self.roles_dir) self.service_registry = build_service_registry_from_roles_dir(self.roles_dir) diff --git a/tests/integration/services/test_mailu_dependency.py b/tests/integration/services/test_mailu_dependency.py index bb464c3007..ccc7bd3e54 100644 --- a/tests/integration/services/test_mailu_dependency.py +++ b/tests/integration/services/test_mailu_dependency.py @@ -4,6 +4,8 @@ from pathlib import Path import yaml +from . import PROJECT_ROOT + _MAILU_ROLE = "web-app-mailu" _EMAIL_SERVICE_KEY = "email" @@ -40,10 +42,8 @@ class TestMailuServiceDependency(unittest.TestCase): must declare services.email with enabled: true and shared: true in its config/main.yml.""" - PROJECT_ROOT = Path(__file__).resolve().parents[3] - def setUp(self): - self.roles_root = self.PROJECT_ROOT / "roles" + self.roles_root = PROJECT_ROOT / "roles" self.assertTrue( self.roles_root.is_dir(), f"Roles directory not found: {self.roles_root}", @@ -90,7 +90,7 @@ class TestMailuServiceDependency(unittest.TestCase): reason_str = " and ".join(reasons) email_svc = self._email_service_conf(config_path) - rel = config_path.relative_to(self.PROJECT_ROOT) + rel = config_path.relative_to(PROJECT_ROOT) if not email_svc.get("enabled"): errors.append( diff --git a/tests/integration/services/test_resolvable.py b/tests/integration/services/test_resolvable.py index fe13ee1e4c..589ef26c10 100644 --- a/tests/integration/services/test_resolvable.py +++ b/tests/integration/services/test_resolvable.py @@ -1,5 +1,4 @@ import unittest -from pathlib import Path from plugins.lookup.service import LookupModule from utils.service_registry import ( @@ -7,12 +6,14 @@ from utils.service_registry import ( load_applications_from_roles_dir, ) +from . import PROJECT_ROOT + class TestServicesResolvable(unittest.TestCase): """Discovered service keys and provider roles must resolve via the service lookup.""" def setUp(self): - self.repo_root = Path(__file__).resolve().parents[3] + self.repo_root = PROJECT_ROOT self.roles_dir = self.repo_root / "roles" self.applications = load_applications_from_roles_dir(self.roles_dir) self.service_registry = build_service_registry_from_roles_dir(self.roles_dir) diff --git a/tests/integration/services/test_transitive_dependencies.py b/tests/integration/services/test_transitive_dependencies.py index 2cfff2a38b..f52cfce061 100644 --- a/tests/integration/services/test_transitive_dependencies.py +++ b/tests/integration/services/test_transitive_dependencies.py @@ -1,14 +1,14 @@ import unittest -from pathlib import Path import yaml from plugins.lookup.service import LookupModule +from . import PROJECT_ROOT class TestServiceTransitiveDependencies(unittest.TestCase): def setUp(self): - self.repo_root = Path(__file__).resolve().parents[3] + self.repo_root = PROJECT_ROOT self.applications = { "web-app-dashboard": self._load_yaml( "roles", "web-app-dashboard", "meta", "services.yml" diff --git a/tests/integration/vars/__init__.py b/tests/integration/vars/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/vars/__init__.py +++ b/tests/integration/vars/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/vars/test_facts_not_overridden.py b/tests/integration/vars/test_facts_not_overridden.py index ca40b66f6d..bd943ac852 100644 --- a/tests/integration/vars/test_facts_not_overridden.py +++ b/tests/integration/vars/test_facts_not_overridden.py @@ -5,6 +5,7 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple import yaml from utils.cache.files import read_text +from . import PROJECT_ROOT def _safe_yaml_load_all(path: Path) -> List[Any]: @@ -188,7 +189,7 @@ class TestFactsAreNotOverriddenByVars(unittest.TestCase): """ def test_no_fact_is_overridden_via_vars_on_includes(self) -> None: - repo_root = Path(__file__).resolve().parents[3] + repo_root = PROJECT_ROOT task_files = _find_task_files(repo_root) facts = _collect_defined_facts(task_files) # fact -> {paths} diff --git a/tests/lint/ansible/roles/test_playwright_env_keys_used.py b/tests/lint/ansible/roles/test_playwright_env_keys_used.py index 996f6242ea..eb2d79af96 100644 --- a/tests/lint/ansible/roles/test_playwright_env_keys_used.py +++ b/tests/lint/ansible/roles/test_playwright_env_keys_used.py @@ -110,6 +110,7 @@ class TestPlaywrightEnvKeysUsed(unittest.TestCase): unreferenced_keys: List[str] = [] for env_path in sorted(roles_dir.glob("*/templates/playwright.env.j2")): + # nocheck: project-root-import walking from a discovered glob match (/templates/...) up to its role dir, not the repo root role_dir = env_path.parents[1] role_name = role_dir.name spec_path = role_dir / "files" / "playwright.spec.js" diff --git a/tests/lint/ansible/test_no_sh_lc_pipefail.py b/tests/lint/ansible/test_no_sh_lc_pipefail.py index 3f1443d392..0c8a068481 100644 --- a/tests/lint/ansible/test_no_sh_lc_pipefail.py +++ b/tests/lint/ansible/test_no_sh_lc_pipefail.py @@ -5,6 +5,7 @@ import unittest from dataclasses import dataclass from pathlib import Path from typing import Iterable, Set +from . import PROJECT_ROOT @dataclass(frozen=True) @@ -18,11 +19,6 @@ class Finding: return f"{rel}:{self.line}: {self.snippet}" -def _repo_root() -> Path: - # tests/integration//.py -> repo root - return Path(__file__).resolve().parents[3] - - def _iter_target_files(repo_root: Path) -> Iterable[Path]: target = repo_root / "cli" / "deploy" if not target.is_dir(): @@ -127,7 +123,7 @@ class TestNoShLcPipefail(unittest.TestCase): `pipefail` requires bash. Using it via `sh -lc` is not portable and breaks on Debian/Ubuntu (dash) with: `set: Illegal option -o pipefail`. """ - repo_root = _repo_root() + repo_root = PROJECT_ROOT findings: list[Finding] = [] for py_file in _iter_target_files(repo_root): findings.extend(_scan_file(py_file)) diff --git a/tests/lint/docker/dockerfile/__init__.py b/tests/lint/docker/dockerfile/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/lint/docker/dockerfile/__init__.py +++ b/tests/lint/docker/dockerfile/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/lint/docker/dockerfile/test_arg_scope.py b/tests/lint/docker/dockerfile/test_arg_scope.py index b938e6f799..c5ab7851cd 100644 --- a/tests/lint/docker/dockerfile/test_arg_scope.py +++ b/tests/lint/docker/dockerfile/test_arg_scope.py @@ -19,8 +19,9 @@ from __future__ import annotations import re import unittest from pathlib import Path +from . import PROJECT_ROOT -_REPO_ROOT = Path(__file__).resolve().parents[4] +_REPO_ROOT = PROJECT_ROOT _ROLES_ROOT = _REPO_ROOT / "roles" # Matches a FROM line that references at least one ${VAR} diff --git a/tests/lint/docker/dockerfile/test_templates.py b/tests/lint/docker/dockerfile/test_templates.py index 16d15f0c42..09d9db8f71 100644 --- a/tests/lint/docker/dockerfile/test_templates.py +++ b/tests/lint/docker/dockerfile/test_templates.py @@ -23,8 +23,9 @@ import unittest from pathlib import Path from utils.annotations.message import warning +from . import PROJECT_ROOT -_REPO_ROOT = Path(__file__).resolve().parents[4] +_REPO_ROOT = PROJECT_ROOT _ROLES_ROOT = _REPO_ROOT / "roles" # Matches any Jinja2 control-flow or structural tag: {% ... %} @@ -39,6 +40,7 @@ def _has_j2_logic(dockerfile_j2: Path) -> bool: def _collect_templated_dockerfiles() -> list[Path]: templated: list[Path] = [] for dockerfile_j2 in sorted(_ROLES_ROOT.glob("*/templates/Dockerfile.j2")): + # nocheck: project-root-import walking from a discovered glob match (/templates/...) up to its role dir, not the repo root plain_dockerfile = dockerfile_j2.parents[1] / "files" / "Dockerfile" if plain_dockerfile.is_file(): continue diff --git a/tests/lint/filesystem/test_init_files.py b/tests/lint/filesystem/test_init_files.py index cd955a557d..23eedee31c 100644 --- a/tests/lint/filesystem/test_init_files.py +++ b/tests/lint/filesystem/test_init_files.py @@ -1,5 +1,5 @@ import unittest -from pathlib import Path +from . import PROJECT_ROOT class TestInitFiles(unittest.TestCase): @@ -7,7 +7,7 @@ class TestInitFiles(unittest.TestCase): """ Ensure every subdirectory in the 'tests' folder (excluding '__pycache__') contains an '__init__.py' file. """ - tests_root = Path(__file__).resolve().parents[3] / "tests" + tests_root = PROJECT_ROOT / "tests" for path in tests_root.rglob("*"): if path.is_dir() and "__pycache__" not in path.parts: diff --git a/tests/lint/filesystem/test_python_shebangs.py b/tests/lint/filesystem/test_python_shebangs.py index b44571ad0e..e9897d0eaf 100644 --- a/tests/lint/filesystem/test_python_shebangs.py +++ b/tests/lint/filesystem/test_python_shebangs.py @@ -6,6 +6,7 @@ import os import subprocess import unittest from pathlib import Path +from . import PROJECT_ROOT class TestPythonShebangs(unittest.TestCase): @@ -14,7 +15,7 @@ class TestPythonShebangs(unittest.TestCase): @classmethod def setUpClass(cls): - cls.repo_root = Path(__file__).resolve().parents[3] + cls.repo_root = PROJECT_ROOT def _tracked_files(self) -> list[Path]: try: diff --git a/tests/lint/filesystem/test_shell_executable.py b/tests/lint/filesystem/test_shell_executable.py index e7ade616f0..8a67a6f5c7 100644 --- a/tests/lint/filesystem/test_shell_executable.py +++ b/tests/lint/filesystem/test_shell_executable.py @@ -4,13 +4,13 @@ from __future__ import annotations import stat import unittest -from pathlib import Path +from . import PROJECT_ROOT class TestShellScriptsExecutable(unittest.TestCase): @classmethod def setUpClass(cls): - cls.repo_root = Path(__file__).resolve().parents[3] + cls.repo_root = PROJECT_ROOT cls.scripts_root = cls.repo_root / "scripts" def test_all_shell_scripts_under_scripts_are_executable(self): diff --git a/tests/lint/repository/test_packaging_python_requirements.py b/tests/lint/repository/test_packaging_python_requirements.py index e707262cbb..d0e24a1104 100644 --- a/tests/lint/repository/test_packaging_python_requirements.py +++ b/tests/lint/repository/test_packaging_python_requirements.py @@ -4,13 +4,13 @@ from __future__ import annotations import re import unittest -from pathlib import Path +from . import PROJECT_ROOT class TestPackagingPythonRequirements(unittest.TestCase): @classmethod def setUpClass(cls): - cls.repo_root = Path(__file__).resolve().parents[3] + cls.repo_root = PROJECT_ROOT cls.arch_pkgbuild = cls.repo_root / "packaging" / "arch" / "PKGBUILD" cls.fedora_spec = cls.repo_root / "packaging" / "fedora" / "infinito-nexus.spec" cls.debian_control = cls.repo_root / "packaging" / "debian" / "control" diff --git a/tests/unit/cli/meta/applications/resolution/affected/test_main.py b/tests/unit/cli/meta/applications/resolution/affected/test_main.py index 6a1d554480..18b081bf32 100644 --- a/tests/unit/cli/meta/applications/resolution/affected/test_main.py +++ b/tests/unit/cli/meta/applications/resolution/affected/test_main.py @@ -53,7 +53,7 @@ class TestAffected(unittest.TestCase): _mk_app_role(root, "leaf", "leaf") _mk_app_role(root, "other", "other") - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertEqual( affected_main.affected_roles(["leaf"]), ["leaf"], @@ -66,7 +66,7 @@ class TestAffected(unittest.TestCase): _mk_app_role(root, "consumer", "consumer") _write_run_after(root, "consumer", ["leaf"]) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertEqual( affected_main.affected_roles(["leaf"]), ["consumer", "leaf"], @@ -81,7 +81,7 @@ class TestAffected(unittest.TestCase): _write_dependencies(root, "mid", ["leaf"]) _write_dependencies(root, "top", ["mid"]) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertEqual( affected_main.affected_roles(["leaf"]), ["leaf", "mid", "top"], @@ -102,7 +102,7 @@ class TestAffected(unittest.TestCase): encoding="utf-8", ) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): got = affected_main.affected_roles(["web-app-keycloak"]) self.assertIn("web-app-keycloak", got) self.assertIn("web-app-consumer", got) @@ -111,7 +111,7 @@ class TestAffected(unittest.TestCase): with tempfile.TemporaryDirectory() as td: root = Path(td) _mk_app_role(root, "leaf", "leaf") - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): with self.assertRaises(SystemExit): affected_main.affected_roles(["does-not-exist"]) @@ -121,7 +121,7 @@ class TestAffected(unittest.TestCase): _mk_non_app_role(root, "sys-helper") _mk_app_role(root, "consumer", "consumer") - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): with self.assertRaises(SystemExit) as ctx: affected_main.affected_roles(["sys-helper"]) self.assertEqual( @@ -136,7 +136,7 @@ class TestAffected(unittest.TestCase): _mk_app_role(root, "consumer", "consumer") _write_run_after(root, "consumer", ["sys-helper"]) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): got = affected_main.affected_roles(["sys-helper"]) self.assertIn("sys-helper", got) self.assertIn("consumer", got) @@ -148,7 +148,7 @@ class TestAffected(unittest.TestCase): _mk_app_role(root, "consumer", "consumer") _write_run_after(root, "consumer", ["leaf"]) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): buf = io.StringIO() with redirect_stdout(buf): with patch( diff --git a/tests/unit/cli/meta/applications/resolution/combined/test_main.py b/tests/unit/cli/meta/applications/resolution/combined/test_main.py index a182fa7b55..bf94baa436 100644 --- a/tests/unit/cli/meta/applications/resolution/combined/test_main.py +++ b/tests/unit/cli/meta/applications/resolution/combined/test_main.py @@ -49,7 +49,7 @@ class TestCombinedMain(unittest.TestCase): _mk_role(root, "ra1", app_id="ra1") _write_meta_services_run_after(root, "app", run_after=["ra1"]) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): buf = io.StringIO() with redirect_stdout(buf): with patch("sys.argv", ["prog", "app"]): @@ -64,7 +64,7 @@ class TestCombinedMain(unittest.TestCase): _mk_role(root, "ra1", app_id="ra1") _write_meta_services_run_after(root, "app", run_after=["ra1"]) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): buf = io.StringIO() with redirect_stdout(buf): with patch("sys.argv", ["prog", "app", "--tree"]): diff --git a/tests/unit/cli/meta/applications/resolution/combined/test_repo_paths.py b/tests/unit/cli/meta/applications/resolution/combined/test_repo_paths.py index 2d625fb96f..10974552a5 100644 --- a/tests/unit/cli/meta/applications/resolution/combined/test_repo_paths.py +++ b/tests/unit/cli/meta/applications/resolution/combined/test_repo_paths.py @@ -12,7 +12,7 @@ class TestCombinedRepoPaths(unittest.TestCase): def test_roles_dir_and_role_paths(self) -> None: root = Path("/tmp/fake-root") - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertEqual(repo_paths.roles_dir(), root / "roles") self.assertEqual( repo_paths.role_dir("web-app-x"), root / "roles" / "web-app-x" diff --git a/tests/unit/cli/meta/applications/resolution/combined/test_resolver.py b/tests/unit/cli/meta/applications/resolution/combined/test_resolver.py index 6895dd78c9..4466d75a06 100644 --- a/tests/unit/cli/meta/applications/resolution/combined/test_resolver.py +++ b/tests/unit/cli/meta/applications/resolution/combined/test_resolver.py @@ -63,7 +63,7 @@ class TestCombinedTree(unittest.TestCase): (root / "roles" / "web-app-keycloak").mkdir(parents=True, exist_ok=True) (root / "roles" / "web-app-dashboard").mkdir(parents=True, exist_ok=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): buf = io.StringIO() with redirect_stdout(buf): print_tree("start") diff --git a/tests/unit/cli/meta/applications/resolution/combined/test_role_introspection.py b/tests/unit/cli/meta/applications/resolution/combined/test_role_introspection.py index 112f610837..3286cb5f49 100644 --- a/tests/unit/cli/meta/applications/resolution/combined/test_role_introspection.py +++ b/tests/unit/cli/meta/applications/resolution/combined/test_role_introspection.py @@ -39,7 +39,7 @@ class TestRoleIntrospection(unittest.TestCase): root = Path(td) _mk_role(root, "web-app-a", app_id="web-app-a") - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): ri.require_role_exists("web-app-a") with self.assertRaises(CombinedResolutionError): ri.require_role_exists("missing-role") @@ -50,7 +50,7 @@ class TestRoleIntrospection(unittest.TestCase): _mk_role(root, "web-app-a", app_id="web-app-a") _mk_role(root, "sys-helper", app_id=None) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertTrue(ri.has_application_id("web-app-a")) self.assertFalse(ri.has_application_id("sys-helper")) @@ -75,7 +75,7 @@ a: """, ) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): ra = ri.load_run_after("web-app-a") self.assertEqual(ra, ["dep1", "dep2"]) @@ -91,7 +91,7 @@ a: run_after: dep1 """, ) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): with self.assertRaises(CombinedResolutionError): ri.load_run_after("web-app-a") @@ -112,7 +112,7 @@ dependencies: """, ) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): deps = ri.load_dependencies_app_only("web-app-a") # sys-helper should be ignored (no application_id) self.assertEqual(deps, ["web-app-b"]) @@ -134,7 +134,7 @@ dependencies: """, ) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): deps = ri.load_dependencies_app_only("web-app-a") self.assertEqual(deps, ["web-app-b"]) @@ -151,6 +151,6 @@ dependencies: """, ) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): with self.assertRaises(CombinedResolutionError): ri.load_dependencies_app_only("web-app-a") diff --git a/tests/unit/cli/meta/applications/resolution/combined/test_tree.py b/tests/unit/cli/meta/applications/resolution/combined/test_tree.py index 9507dabb9c..c6bd4df27f 100644 --- a/tests/unit/cli/meta/applications/resolution/combined/test_tree.py +++ b/tests/unit/cli/meta/applications/resolution/combined/test_tree.py @@ -66,7 +66,7 @@ class TestCombinedTree(unittest.TestCase): (root / "roles" / "web-app-keycloak").mkdir(parents=True, exist_ok=True) (root / "roles" / "web-app-dashboard").mkdir(parents=True, exist_ok=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): buf = io.StringIO() with redirect_stdout(buf): print_tree("start") diff --git a/tests/unit/cli/meta/applications/resolution/dependencies/test_dependencies_main.py b/tests/unit/cli/meta/applications/resolution/dependencies/test_dependencies_main.py index 8c8a4eb837..eaf6564c33 100644 --- a/tests/unit/cli/meta/applications/resolution/dependencies/test_dependencies_main.py +++ b/tests/unit/cli/meta/applications/resolution/dependencies/test_dependencies_main.py @@ -47,7 +47,7 @@ dependencies: """, ) - with patch.object(deps_main, "repo_root_from_here", return_value=root): + with patch.object(deps_main, "PROJECT_ROOT", root): out = deps_main.resolve_dependencies_transitively("app") self.assertEqual(out, ["app_dep"]) @@ -66,7 +66,7 @@ dependencies: """, ) - with patch.object(deps_main, "repo_root_from_here", return_value=root): + with patch.object(deps_main, "PROJECT_ROOT", root): buf = io.StringIO() with redirect_stdout(buf): with patch("sys.argv", ["prog", "app"]): diff --git a/tests/unit/cli/meta/applications/resolution/services/__init__.py b/tests/unit/cli/meta/applications/resolution/services/__init__.py index 8b13789179..86efabd459 100644 --- a/tests/unit/cli/meta/applications/resolution/services/__init__.py +++ b/tests/unit/cli/meta/applications/resolution/services/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[7] diff --git a/tests/unit/cli/meta/applications/resolution/services/test_resolver.py b/tests/unit/cli/meta/applications/resolution/services/test_resolver.py index b1c8683f82..4bb12e48f7 100644 --- a/tests/unit/cli/meta/applications/resolution/services/test_resolver.py +++ b/tests/unit/cli/meta/applications/resolution/services/test_resolver.py @@ -9,6 +9,8 @@ from cli.meta.applications.resolution.services.resolver import ( resolve_direct_service_roles_from_config, ) +from . import PROJECT_ROOT + _MINIMAL_SERVICE_REGISTRY = { "ldap": {"role": "svc-db-openldap"}, @@ -110,7 +112,7 @@ class TestServicesResolverTransitive(unittest.TestCase): self.assertEqual(got, ["web-app-keycloak", "web-app-matomo"]) def test_repo_pixelfed_includes_dashboard(self) -> None: - repo_root = Path(__file__).resolve().parents[7] + repo_root = PROJECT_ROOT resolver = ServicesResolver(repo_root / "roles") got = resolver.resolve_transitively("web-app-pixelfed") @@ -118,7 +120,7 @@ class TestServicesResolverTransitive(unittest.TestCase): self.assertIn("web-app-dashboard", got) def test_repo_nextcloud_includes_coturn(self) -> None: - repo_root = Path(__file__).resolve().parents[7] + repo_root = PROJECT_ROOT r = ServicesResolver(repo_root / "roles") got = r.resolve_transitively("web-app-nextcloud") diff --git a/tests/unit/cli/meta/applications/resolution/services/test_role_introspection.py b/tests/unit/cli/meta/applications/resolution/services/test_role_introspection.py index b1ecef395c..d4acf221f0 100644 --- a/tests/unit/cli/meta/applications/resolution/services/test_role_introspection.py +++ b/tests/unit/cli/meta/applications/resolution/services/test_role_introspection.py @@ -28,7 +28,7 @@ class TestCombinedRoleIntrospection(unittest.TestCase): root = Path(td) (root / "roles" / "web-app-x").mkdir(parents=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): require_role_exists("web-app-x") with self.assertRaises(CombinedResolutionError): require_role_exists("missing-role") @@ -39,7 +39,7 @@ class TestCombinedRoleIntrospection(unittest.TestCase): _write(root / "roles" / "a" / "vars" / "main.yml", "application_id: a\n") (root / "roles" / "b").mkdir(parents=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertTrue(has_application_id("a")) self.assertFalse(has_application_id("b")) @@ -55,7 +55,7 @@ class TestCombinedRoleIntrospection(unittest.TestCase): (root / "roles" / "web-app-x").mkdir(parents=True) (root / "roles" / "web-app-y").mkdir(parents=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): self.assertEqual(load_run_after("a"), ["web-app-x", "web-app-y"]) def test_load_dependencies_app_only_filters_non_app_roles(self) -> None: @@ -80,7 +80,7 @@ class TestCombinedRoleIntrospection(unittest.TestCase): # non-app-dep exists but no application_id (root / "roles" / "non-app-dep").mkdir(parents=True, exist_ok=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): deps = load_dependencies_app_only("start") self.assertEqual(deps, ["app-dep"]) @@ -109,7 +109,7 @@ class TestCombinedRoleIntrospection(unittest.TestCase): (root / "roles" / "web-app-keycloak").mkdir(parents=True) (root / "roles" / "web-app-dashboard").mkdir(parents=True) - with patch.object(repo_paths, "repo_root_from_here", return_value=root): + with patch.object(repo_paths, "PROJECT_ROOT", root): roles = load_shared_service_roles_for_app("web-app-wordpress") self.assertEqual(roles, ["web-app-keycloak", "web-app-dashboard"]) diff --git a/tests/unit/plugins/filter/test_get_all_invokable_apps.py b/tests/unit/plugins/filter/test_get_all_invokable_apps.py index acc6d9483b..9bb608163f 100644 --- a/tests/unit/plugins/filter/test_get_all_invokable_apps.py +++ b/tests/unit/plugins/filter/test_get_all_invokable_apps.py @@ -61,7 +61,7 @@ class TestGetAllInvokableApps(unittest.TestCase): def test_get_all_invokable_apps(self): """Should return only applications whose role paths match invokable paths.""" - with patch("utils.invokable._repo_root", return_value=self.test_dir): + with patch("utils.invokable.PROJECT_ROOT", self.test_dir): result = get_all_invokable_apps() expected = sorted( @@ -78,7 +78,7 @@ class TestGetAllInvokableApps(unittest.TestCase): with self.categories_file.open("w", encoding="utf-8") as f: yaml.safe_dump({"roles": {"foo": {"invokable": False}}}, f) - with patch("utils.invokable._repo_root", return_value=self.test_dir): + with patch("utils.invokable.PROJECT_ROOT", self.test_dir): with self.assertRaises(RuntimeError): get_all_invokable_apps() @@ -91,7 +91,7 @@ class TestGetAllInvokableApps(unittest.TestCase): with self.categories_file.open("w", encoding="utf-8") as f: yaml.safe_dump({"roles": {"web": {"app": {"invokable": True}}}}, f) - with patch("utils.invokable._repo_root", return_value=self.test_dir): + with patch("utils.invokable.PROJECT_ROOT", self.test_dir): result = get_all_invokable_apps() self.assertEqual(result, []) @@ -106,7 +106,7 @@ class TestGetAllInvokableApps(unittest.TestCase): """ self.categories_file.unlink() - with patch("utils.invokable._repo_root", return_value=self.test_dir): + with patch("utils.invokable.PROJECT_ROOT", self.test_dir): with patch( "utils.invokable._get_invokable_paths", side_effect=FileNotFoundError, diff --git a/tests/unit/plugins/lookup/__init__.py b/tests/unit/plugins/lookup/__init__.py index 8b13789179..88f288ec1f 100644 --- a/tests/unit/plugins/lookup/__init__.py +++ b/tests/unit/plugins/lookup/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/unit/plugins/lookup/test_compose_file_args.py b/tests/unit/plugins/lookup/test_compose_file_args.py index 7be84158e1..a0765fcfb7 100644 --- a/tests/unit/plugins/lookup/test_compose_file_args.py +++ b/tests/unit/plugins/lookup/test_compose_file_args.py @@ -1,19 +1,14 @@ # tests/unit/plugins/lookup/test_compose_file_args.py import importlib.util import unittest -from pathlib import Path from unittest.mock import patch from ansible.errors import AnsibleError - - -def _repo_root() -> Path: - # __file__ = tests/unit/plugins/lookup/test_compose_file_args.py - return Path(__file__).resolve().parents[4] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str): - path = _repo_root() / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/plugins/lookup/test_container.py b/tests/unit/plugins/lookup/test_container.py index 6b09e78a94..5d7fda8e6c 100644 --- a/tests/unit/plugins/lookup/test_container.py +++ b/tests/unit/plugins/lookup/test_container.py @@ -1,17 +1,13 @@ import importlib.util import unittest -from pathlib import Path from unittest.mock import patch from ansible.errors import AnsibleError - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str): - path = _repo_root() / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/plugins/lookup/test_database.py b/tests/unit/plugins/lookup/test_database.py index 47ddd0d399..80b667ca17 100644 --- a/tests/unit/plugins/lookup/test_database.py +++ b/tests/unit/plugins/lookup/test_database.py @@ -1,19 +1,14 @@ # tests/unit/plugins/lookup/test_database.py import importlib.util import unittest -from pathlib import Path from unittest.mock import patch from ansible.errors import AnsibleError - - -def _repo_root() -> Path: - # __file__ = tests/unit/plugins/lookup/test_database.py - return Path(__file__).resolve().parents[4] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str): - path = _repo_root() / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/plugins/lookup/test_oidc_flavor.py b/tests/unit/plugins/lookup/test_oidc_flavor.py index f62263c8fb..0bc8bafc0e 100644 --- a/tests/unit/plugins/lookup/test_oidc_flavor.py +++ b/tests/unit/plugins/lookup/test_oidc_flavor.py @@ -2,13 +2,9 @@ import importlib import importlib.util import sys import unittest -from pathlib import Path from ansible.errors import AnsibleError - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[4] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str): @@ -28,7 +24,7 @@ def _load_module(rel_path: str, name: str): importlib.import_module("utils.applications.config") importlib.import_module("utils.cache.applications") - path = _repo_root() / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/roles/svc-bkp-rmt-2-loc/files/__init__.py b/tests/unit/roles/svc-bkp-rmt-2-loc/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/svc-bkp-rmt-2-loc/files/__init__.py +++ b/tests/unit/roles/svc-bkp-rmt-2-loc/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/svc-bkp-rmt-2-loc/files/test_pull_specific_host.py b/tests/unit/roles/svc-bkp-rmt-2-loc/files/test_pull_specific_host.py index bada92c9ec..8baa5445e2 100644 --- a/tests/unit/roles/svc-bkp-rmt-2-loc/files/test_pull_specific_host.py +++ b/tests/unit/roles/svc-bkp-rmt-2-loc/files/test_pull_specific_host.py @@ -1,9 +1,9 @@ import unittest import types -from pathlib import Path from unittest.mock import patch import subprocess import os +from . import PROJECT_ROOT def load_module(): @@ -12,11 +12,8 @@ def load_module(): roles/svc-bkp-rmt-2-loc/files/pull-specific-host.py relative to this test file. """ - here = Path(__file__).resolve() - # tests/unit/roles/svc-bkp-rmt-2-loc/files -> up 5 levels to repo root - repo_root = here.parents[5] target_path = ( - repo_root / "roles" / "svc-bkp-rmt-2-loc" / "files" / "pull-specific-host.py" + PROJECT_ROOT / "roles" / "svc-bkp-rmt-2-loc" / "files" / "pull-specific-host.py" ) if not target_path.exists(): raise FileNotFoundError(f"Cannot find script at {target_path}") diff --git a/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/__init__.py b/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/__init__.py +++ b/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/test_file_has_content.py b/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/test_file_has_content.py index ed4a958815..559981ee93 100644 --- a/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/test_file_has_content.py +++ b/tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/test_file_has_content.py @@ -8,6 +8,7 @@ import unittest from pathlib import Path from unittest.mock import patch import importlib.util +from . import PROJECT_ROOT class _ExitJson(Exception): @@ -48,19 +49,8 @@ def _import_module_under_test(): tests/unit/roles/sys-ctl-bkp-docker-2-loc/library/test_file_has_content.py Repo root is parents[5] from the test file path. """ - this_file = Path(__file__).resolve() - - # parents: - # [0]=.../library - # [1]=.../sys-ctl-bkp-docker-2-loc - # [2]=.../roles - # [3]=.../unit - # [4]=.../tests - # [5]=.../ (repo root) - repo_root = this_file.parents[5] - module_path = ( - repo_root + PROJECT_ROOT / "roles" / "sys-ctl-bkp-docker-2-loc" / "library" diff --git a/tests/unit/roles/sys-ctl-cln-bkps/files/__init__.py b/tests/unit/roles/sys-ctl-cln-bkps/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-ctl-cln-bkps/files/__init__.py +++ b/tests/unit/roles/sys-ctl-cln-bkps/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py b/tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py index 5e34453560..d0a595f30c 100644 --- a/tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py +++ b/tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py @@ -11,25 +11,20 @@ from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch +from . import PROJECT_ROOT + def _repo_root() -> Path: - """ - Resolve repository root in a way that works locally and in CI. + """Repository root resolution. - Priority: - 1. INFINITO_REPO_ROOT env var (if explicitly set) - 2. Derive from this file location - - This file path: - /tests/unit/roles/sys-ctl-cln-bkps/files/test_script.py + The ``INFINITO_REPO_ROOT`` env var still wins when explicitly set + (used by some CI fixtures); otherwise the canonical ``PROJECT_ROOT`` + constant from the package ``__init__.py`` is returned. """ env = os.environ.get("INFINITO_REPO_ROOT") if env: return Path(env).expanduser().resolve() - - # parents: - # files -> sys-ctl-cln-bkps -> roles -> unit -> tests -> - return Path(__file__).resolve().parents[5] + return PROJECT_ROOT SCRIPT_PATH = _repo_root() / "roles/sys-ctl-cln-bkps/files/script.py" diff --git a/tests/unit/roles/sys-ctl-hlth-csp/files/__init__.py b/tests/unit/roles/sys-ctl-hlth-csp/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-ctl-hlth-csp/files/__init__.py +++ b/tests/unit/roles/sys-ctl-hlth-csp/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-ctl-hlth-csp/files/test_script.py b/tests/unit/roles/sys-ctl-hlth-csp/files/test_script.py index d8a0d1295a..4abb9fbe11 100644 --- a/tests/unit/roles/sys-ctl-hlth-csp/files/test_script.py +++ b/tests/unit/roles/sys-ctl-hlth-csp/files/test_script.py @@ -5,9 +5,10 @@ import sys import unittest from pathlib import Path from unittest.mock import MagicMock, patch +from . import PROJECT_ROOT # add role files directory to PYTHONPATH -ROLE_FILES = Path(__file__).resolve().parents[5] / "roles/sys-ctl-hlth-csp/files" +ROLE_FILES = PROJECT_ROOT / "roles/sys-ctl-hlth-csp/files" sys.path.insert(0, str(ROLE_FILES)) import script # noqa: E402 diff --git a/tests/unit/roles/sys-ctl-hlth-disc-space/files/__init__.py b/tests/unit/roles/sys-ctl-hlth-disc-space/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-ctl-hlth-disc-space/files/__init__.py +++ b/tests/unit/roles/sys-ctl-hlth-disc-space/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-ctl-hlth-disc-space/files/test_script.py b/tests/unit/roles/sys-ctl-hlth-disc-space/files/test_script.py index 7439193c7d..1d14025abc 100644 --- a/tests/unit/roles/sys-ctl-hlth-disc-space/files/test_script.py +++ b/tests/unit/roles/sys-ctl-hlth-disc-space/files/test_script.py @@ -4,12 +4,12 @@ import sys import contextlib import importlib.util from types import SimpleNamespace -from pathlib import Path from unittest import TestCase, main, mock +from . import PROJECT_ROOT def load_target_module(): - repo_root = Path(__file__).resolve().parents[5] # /opt/src/infinito + repo_root = PROJECT_ROOT # /opt/src/infinito script_path = ( repo_root / "roles" / "sys-ctl-hlth-disc-space" / "files" / "script.py" ) diff --git a/tests/unit/roles/sys-ctl-rpr-container-hard/files/__init__.py b/tests/unit/roles/sys-ctl-rpr-container-hard/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-ctl-rpr-container-hard/files/__init__.py +++ b/tests/unit/roles/sys-ctl-rpr-container-hard/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-ctl-rpr-container-hard/files/test_script.py b/tests/unit/roles/sys-ctl-rpr-container-hard/files/test_script.py index f6405862db..b34cc4fb50 100644 --- a/tests/unit/roles/sys-ctl-rpr-container-hard/files/test_script.py +++ b/tests/unit/roles/sys-ctl-rpr-container-hard/files/test_script.py @@ -1,17 +1,15 @@ import unittest import sys -from pathlib import Path from importlib.util import spec_from_file_location, module_from_spec +from . import PROJECT_ROOT def load_script_module(): """ Import the script under test from roles/sys-ctl-rpr-container-hard/files/script.py """ - test_file = Path(__file__).resolve() - repo_root = test_file.parents[5] script_path = ( - repo_root / "roles" / "sys-ctl-rpr-container-hard" / "files" / "script.py" + PROJECT_ROOT / "roles" / "sys-ctl-rpr-container-hard" / "files" / "script.py" ) if not script_path.exists(): raise FileNotFoundError(f"script.py not found at {script_path}") diff --git a/tests/unit/roles/sys-ctl-rpr-container-soft/files/__init__.py b/tests/unit/roles/sys-ctl-rpr-container-soft/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-ctl-rpr-container-soft/files/__init__.py +++ b/tests/unit/roles/sys-ctl-rpr-container-soft/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-ctl-rpr-container-soft/files/test_script.py b/tests/unit/roles/sys-ctl-rpr-container-soft/files/test_script.py index 5a8a0991a0..79f153726a 100644 --- a/tests/unit/roles/sys-ctl-rpr-container-soft/files/test_script.py +++ b/tests/unit/roles/sys-ctl-rpr-container-soft/files/test_script.py @@ -1,17 +1,15 @@ import unittest import types -from pathlib import Path from importlib.util import spec_from_file_location, module_from_spec +from . import PROJECT_ROOT def load_script_module(): """ Import the script under test from roles/sys-ctl-rpr-container-soft/files/script.py """ - test_file = Path(__file__).resolve() - repo_root = test_file.parents[5] script_path = ( - repo_root / "roles" / "sys-ctl-rpr-container-soft" / "files" / "script.py" + PROJECT_ROOT / "roles" / "sys-ctl-rpr-container-soft" / "files" / "script.py" ) if not script_path.exists(): raise FileNotFoundError(f"script.py not found at {script_path}") diff --git a/tests/unit/roles/sys-dns-wildcards/filter_plugins/__init__.py b/tests/unit/roles/sys-dns-wildcards/filter_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-dns-wildcards/filter_plugins/__init__.py +++ b/tests/unit/roles/sys-dns-wildcards/filter_plugins/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-dns-wildcards/filter_plugins/test_wildcard_dns.py b/tests/unit/roles/sys-dns-wildcards/filter_plugins/test_wildcard_dns.py index 2a8067d82b..779975d5ad 100644 --- a/tests/unit/roles/sys-dns-wildcards/filter_plugins/test_wildcard_dns.py +++ b/tests/unit/roles/sys-dns-wildcards/filter_plugins/test_wildcard_dns.py @@ -1,7 +1,7 @@ # tests/unit/roles/sys-dns-wildcards/filter_plugins/test_wildcard_dns.py import unittest import importlib.util -from pathlib import Path +from . import PROJECT_ROOT def _load_module(): @@ -9,12 +9,12 @@ def _load_module(): Load the wildcard_dns filter plugin from: roles/sys-dns-wildcards/filter_plugins/wildcard_dns.py """ - here = Path(__file__).resolve() - # Go up to repository root (…/tests/unit/roles/… → 5 levels up) - repo_root = here.parents[5] if len(here.parents) >= 6 else here.parents[0] - path = ( - repo_root / "roles" / "sys-dns-wildcards" / "filter_plugins" / "wildcard_dns.py" + PROJECT_ROOT + / "roles" + / "sys-dns-wildcards" + / "filter_plugins" + / "wildcard_dns.py" ) if not path.exists(): raise FileNotFoundError(f"Could not find {path}") diff --git a/tests/unit/roles/sys-front-inj-all/filter_plugins/__init__.py b/tests/unit/roles/sys-front-inj-all/filter_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-front-inj-all/filter_plugins/__init__.py +++ b/tests/unit/roles/sys-front-inj-all/filter_plugins/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-front-inj-all/filter_plugins/test_inj_enabled.py b/tests/unit/roles/sys-front-inj-all/filter_plugins/test_inj_enabled.py index f52a04d459..4815809d17 100644 --- a/tests/unit/roles/sys-front-inj-all/filter_plugins/test_inj_enabled.py +++ b/tests/unit/roles/sys-front-inj-all/filter_plugins/test_inj_enabled.py @@ -1,25 +1,10 @@ import importlib.util from importlib import import_module -from pathlib import Path import sys import unittest -THIS_FILE = Path(__file__) +from . import PROJECT_ROOT - -def find_repo_root(start: Path) -> Path: - target_rel = ( - Path("roles") / "sys-front-inj-all" / "filter_plugins" / "inj_enabled.py" - ) - cur = start - for _ in range(12): - if (cur / target_rel).is_file(): - return cur - cur = cur.parent - return start.parents[6] - - -PROJECT_ROOT = find_repo_root(THIS_FILE) PLUGIN_PATH = ( PROJECT_ROOT / "roles" / "sys-front-inj-all" / "filter_plugins" / "inj_enabled.py" ) diff --git a/tests/unit/roles/sys-svc-compose-ca/files/__init__.py b/tests/unit/roles/sys-svc-compose-ca/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-svc-compose-ca/files/__init__.py +++ b/tests/unit/roles/sys-svc-compose-ca/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-svc-compose-ca/files/test_compose_ca.py b/tests/unit/roles/sys-svc-compose-ca/files/test_compose_ca.py index 5a2ef344d9..a48456c221 100644 --- a/tests/unit/roles/sys-svc-compose-ca/files/test_compose_ca.py +++ b/tests/unit/roles/sys-svc-compose-ca/files/test_compose_ca.py @@ -6,15 +6,11 @@ import yaml from pathlib import Path from unittest.mock import patch - -def _repo_root(start: Path) -> Path: - # __file__ = tests/unit/roles/sys-svc-compose-ca/files/test_compose_ca_inject.py - return start.resolve().parents[5] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str): - root = _repo_root(Path(__file__)) - path = root / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/roles/sys-svc-compose/files/__init__.py b/tests/unit/roles/sys-svc-compose/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-svc-compose/files/__init__.py +++ b/tests/unit/roles/sys-svc-compose/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-svc-compose/files/test_pull.py b/tests/unit/roles/sys-svc-compose/files/test_pull.py index 72585c45ac..1313278d6d 100644 --- a/tests/unit/roles/sys-svc-compose/files/test_pull.py +++ b/tests/unit/roles/sys-svc-compose/files/test_pull.py @@ -7,14 +7,11 @@ from types import ModuleType from unittest.mock import patch -def _repo_root(start: Path) -> Path: - # __file__ = tests/unit/roles/sys-svc-compose/files/test_pull.py - return start.resolve().parents[5] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str) -> ModuleType: - root = _repo_root(Path(__file__)) - path = root / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/roles/sys-svc-compose/lookup_plugins/__init__.py b/tests/unit/roles/sys-svc-compose/lookup_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-svc-compose/lookup_plugins/__init__.py +++ b/tests/unit/roles/sys-svc-compose/lookup_plugins/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-svc-compose/lookup_plugins/test_compose_ca_inject_cmd.py b/tests/unit/roles/sys-svc-compose/lookup_plugins/test_compose_ca_inject_cmd.py index e0555160ea..eb9b250430 100644 --- a/tests/unit/roles/sys-svc-compose/lookup_plugins/test_compose_ca_inject_cmd.py +++ b/tests/unit/roles/sys-svc-compose/lookup_plugins/test_compose_ca_inject_cmd.py @@ -5,14 +5,11 @@ from pathlib import Path from unittest.mock import patch from ansible.errors import AnsibleError - - -def _repo_root() -> Path: - return Path(__file__).resolve().parents[5] +from . import PROJECT_ROOT def _load_module(rel_path: str, name: str): - path = _repo_root() / rel_path + path = PROJECT_ROOT / rel_path spec = importlib.util.spec_from_file_location(name, str(path)) mod = importlib.util.module_from_spec(spec) assert spec and spec.loader diff --git a/tests/unit/roles/sys-svc-container/files/__init__.py b/tests/unit/roles/sys-svc-container/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-svc-container/files/__init__.py +++ b/tests/unit/roles/sys-svc-container/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-svc-container/files/test_container.py b/tests/unit/roles/sys-svc-container/files/test_container.py index 5c47a0822f..7de8e064c8 100644 --- a/tests/unit/roles/sys-svc-container/files/test_container.py +++ b/tests/unit/roles/sys-svc-container/files/test_container.py @@ -7,6 +7,7 @@ import os import tempfile import contextlib import io +from . import PROJECT_ROOT # --------------------------------------------------------------------------- # Manual import of roles/sys-svc-container/files/container.py @@ -15,8 +16,6 @@ import io THIS_FILE = Path(__file__).resolve() # tests/unit/roles/sys-svc-container/files/test_container.py -> project root -PROJECT_ROOT = THIS_FILE.parents[5] - CONTAINER_PY = PROJECT_ROOT / "roles" / "sys-svc-container" / "files" / "container.py" if not CONTAINER_PY.exists(): diff --git a/tests/unit/roles/sys-util-git-pull/files/__init__.py b/tests/unit/roles/sys-util-git-pull/files/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/sys-util-git-pull/files/__init__.py +++ b/tests/unit/roles/sys-util-git-pull/files/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/sys-util-git-pull/files/test_sys_util_git_pull.py b/tests/unit/roles/sys-util-git-pull/files/test_sys_util_git_pull.py index 7228e7d7d2..de47c53a89 100644 --- a/tests/unit/roles/sys-util-git-pull/files/test_sys_util_git_pull.py +++ b/tests/unit/roles/sys-util-git-pull/files/test_sys_util_git_pull.py @@ -9,26 +9,21 @@ from __future__ import annotations import importlib.util import io -import pathlib import sys import unittest from contextlib import redirect_stderr, redirect_stdout from unittest.mock import MagicMock, patch +from . import PROJECT_ROOT + def _load_sut(): """ Load roles/sys-util-git-pull/files/sys_util_git_pull.py as module "sys_util_git_pull" so unittest.mock.patch can reference it via "sys_util_git_pull.". """ - this_file = pathlib.Path(__file__).resolve() - - # Expected file location: - # /tests/unit/roles/sys-util-git-pull/files/test_sys_util_git_pull.py - # Repo root is 5 levels up. - repo_root = this_file.parents[5] script_path = ( - repo_root / "roles" / "sys-util-git-pull" / "files" / "sys_util_git_pull.py" + PROJECT_ROOT / "roles" / "sys-util-git-pull" / "files" / "sys_util_git_pull.py" ) if not script_path.exists(): diff --git a/tests/unit/roles/test-e2e-playwright/filter_plugins/__init__.py b/tests/unit/roles/test-e2e-playwright/filter_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/test-e2e-playwright/filter_plugins/__init__.py +++ b/tests/unit/roles/test-e2e-playwright/filter_plugins/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/test-e2e-playwright/filter_plugins/test_discover_playwright_roles.py b/tests/unit/roles/test-e2e-playwright/filter_plugins/test_discover_playwright_roles.py index 76c1f3ba7a..bd62551988 100644 --- a/tests/unit/roles/test-e2e-playwright/filter_plugins/test_discover_playwright_roles.py +++ b/tests/unit/roles/test-e2e-playwright/filter_plugins/test_discover_playwright_roles.py @@ -4,13 +4,12 @@ import unittest from pathlib import Path from ansible.errors import AnsibleFilterError +from . import PROJECT_ROOT def _load_plugin_module(): - here = Path(__file__).resolve() - repo_root = here.parents[5] if len(here.parents) >= 6 else here.parents[0] plugin_path = ( - repo_root + PROJECT_ROOT / "roles" / "test-e2e-playwright" / "filter_plugins" diff --git a/tests/unit/roles/test-e2e-playwright/filter_plugins/test_image_version.py b/tests/unit/roles/test-e2e-playwright/filter_plugins/test_image_version.py index d381bff63d..1ef7dd3344 100644 --- a/tests/unit/roles/test-e2e-playwright/filter_plugins/test_image_version.py +++ b/tests/unit/roles/test-e2e-playwright/filter_plugins/test_image_version.py @@ -1,15 +1,13 @@ import importlib.util import unittest -from pathlib import Path from ansible.errors import AnsibleFilterError +from . import PROJECT_ROOT def _load_plugin_module(): - here = Path(__file__).resolve() - repo_root = here.parents[5] if len(here.parents) >= 6 else here.parents[0] plugin_path = ( - repo_root + PROJECT_ROOT / "roles" / "test-e2e-playwright" / "filter_plugins" diff --git a/tests/unit/roles/web-app-dashboard/filter_plugins/__init__.py b/tests/unit/roles/web-app-dashboard/filter_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/web-app-dashboard/filter_plugins/__init__.py +++ b/tests/unit/roles/web-app-dashboard/filter_plugins/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/web-app-dashboard/filter_plugins/test_simpleicons_source.py b/tests/unit/roles/web-app-dashboard/filter_plugins/test_simpleicons_source.py index 3591627979..3234c8f840 100644 --- a/tests/unit/roles/web-app-dashboard/filter_plugins/test_simpleicons_source.py +++ b/tests/unit/roles/web-app-dashboard/filter_plugins/test_simpleicons_source.py @@ -1,19 +1,17 @@ import importlib.util import os -import pathlib import tempfile import unittest from unittest.mock import patch import certifi +from . import PROJECT_ROOT + def _load_simpleicons_module(): - test_file = pathlib.Path(__file__).resolve() - repo_root = test_file.parents[5] - module_path = ( - repo_root + PROJECT_ROOT / "roles" / "web-app-dashboard" / "filter_plugins" diff --git a/tests/unit/roles/web-app-dashboard/filter_plugins/test_social.py b/tests/unit/roles/web-app-dashboard/filter_plugins/test_social.py index a6cc3b7926..db1bc21da2 100644 --- a/tests/unit/roles/web-app-dashboard/filter_plugins/test_social.py +++ b/tests/unit/roles/web-app-dashboard/filter_plugins/test_social.py @@ -1,9 +1,10 @@ import unittest -import pathlib import importlib.util from ansible.errors import AnsibleFilterError +from . import PROJECT_ROOT + def _load_social_module(): """ @@ -12,13 +13,8 @@ def _load_social_module(): This helper allows the test to be executed from the repository root without requiring the roles directory to be a Python package. """ - # Resolve repository root based on this test file location: - # tests/unit/roles/web-app-dashboard/filter_plugins/test_social.py - test_file = pathlib.Path(__file__).resolve() - repo_root = test_file.parents[5] - social_path = ( - repo_root / "roles" / "web-app-dashboard" / "filter_plugins" / "social.py" + PROJECT_ROOT / "roles" / "web-app-dashboard" / "filter_plugins" / "social.py" ) if not social_path.is_file(): diff --git a/tests/unit/roles/web-app-littlejs/filter_plugins/__init__.py b/tests/unit/roles/web-app-littlejs/filter_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/web-app-littlejs/filter_plugins/__init__.py +++ b/tests/unit/roles/web-app-littlejs/filter_plugins/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/web-app-littlejs/filter_plugins/test_littlejs.py b/tests/unit/roles/web-app-littlejs/filter_plugins/test_littlejs.py index 881a565f19..0f76ddc421 100644 --- a/tests/unit/roles/web-app-littlejs/filter_plugins/test_littlejs.py +++ b/tests/unit/roles/web-app-littlejs/filter_plugins/test_littlejs.py @@ -1,6 +1,6 @@ import unittest import importlib.util -from pathlib import Path +from . import PROJECT_ROOT def _load_littlejs_module(): @@ -8,13 +8,8 @@ def _load_littlejs_module(): Load the littlejs filter plugin directly from the roles path. Works even with hyphens in directory names. """ - here = Path(__file__).resolve() plugin_path = ( - here.parents[5] - / "roles" - / "web-app-littlejs" - / "filter_plugins" - / "littlejs.py" + PROJECT_ROOT / "roles" / "web-app-littlejs" / "filter_plugins" / "littlejs.py" ) spec = importlib.util.spec_from_file_location("littlejs_filter", plugin_path) diff --git a/tests/unit/roles/web-app-peertube/filter_plugins/__init__.py b/tests/unit/roles/web-app-peertube/filter_plugins/__init__.py index e69de29bb2..8ee16d7567 100644 --- a/tests/unit/roles/web-app-peertube/filter_plugins/__init__.py +++ b/tests/unit/roles/web-app-peertube/filter_plugins/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/roles/web-app-peertube/filter_plugins/test_install_mem_limit.py b/tests/unit/roles/web-app-peertube/filter_plugins/test_install_mem_limit.py index fd8d8f921c..dd249b7779 100644 --- a/tests/unit/roles/web-app-peertube/filter_plugins/test_install_mem_limit.py +++ b/tests/unit/roles/web-app-peertube/filter_plugins/test_install_mem_limit.py @@ -1,9 +1,10 @@ import unittest -import pathlib import importlib.util from ansible.errors import AnsibleFilterError +from . import PROJECT_ROOT + def _load_install_mem_limit_module(): """ @@ -11,12 +12,8 @@ def _load_install_mem_limit_module(): roles/web-app-peertube path, without requiring the roles directory to be a Python package. """ - # tests/unit/roles/web-app-peertube/filter_plugins/test_install_mem_limit.py - test_file = pathlib.Path(__file__).resolve() - repo_root = test_file.parents[5] - plugin_path = ( - repo_root + PROJECT_ROOT / "roles" / "web-app-peertube" / "filter_plugins" diff --git a/tests/unit/roles/web_app_keycloak/__init__.py b/tests/unit/roles/web_app_keycloak/__init__.py index 8b13789179..88f288ec1f 100644 --- a/tests/unit/roles/web_app_keycloak/__init__.py +++ b/tests/unit/roles/web_app_keycloak/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/unit/roles/web_app_keycloak/test_filter_plugins_kcadm.py b/tests/unit/roles/web_app_keycloak/test_filter_plugins_kcadm.py index 2c3b4022dc..7bc1b1a9ff 100644 --- a/tests/unit/roles/web_app_keycloak/test_filter_plugins_kcadm.py +++ b/tests/unit/roles/web_app_keycloak/test_filter_plugins_kcadm.py @@ -4,9 +4,8 @@ from __future__ import annotations import unittest from pathlib import Path import importlib.util +from . import PROJECT_ROOT - -PROJECT_ROOT = Path(__file__).resolve().parents[4] # /opt/src/infinito ROLE_DIR = PROJECT_ROOT / "roles" / "web-app-keycloak" FILTER_PATH = ROLE_DIR / "filter_plugins" / "kcadm.py" MODUTILS_PATH = ROLE_DIR / "module_utils" / "kcadm_json.py" diff --git a/tests/unit/roles/web_app_keycloak/test_library_keycloak_kcadm_update.py b/tests/unit/roles/web_app_keycloak/test_library_keycloak_kcadm_update.py index d647f56a02..45da233827 100644 --- a/tests/unit/roles/web_app_keycloak/test_library_keycloak_kcadm_update.py +++ b/tests/unit/roles/web_app_keycloak/test_library_keycloak_kcadm_update.py @@ -7,9 +7,8 @@ import importlib.util import sys import types from unittest.mock import patch +from . import PROJECT_ROOT - -PROJECT_ROOT = Path(__file__).resolve().parents[4] # /opt/src/infinito ROLE_DIR = PROJECT_ROOT / "roles" / "web-app-keycloak" LIB_PATH = ROLE_DIR / "library" / "keycloak_kcadm_update.py" MODUTILS_PATH = ROLE_DIR / "module_utils" / "kcadm_json.py" diff --git a/tests/unit/roles/web_app_keycloak/test_module_utils_kcadm_json.py b/tests/unit/roles/web_app_keycloak/test_module_utils_kcadm_json.py index 5671a1de4c..9e8926f75b 100644 --- a/tests/unit/roles/web_app_keycloak/test_module_utils_kcadm_json.py +++ b/tests/unit/roles/web_app_keycloak/test_module_utils_kcadm_json.py @@ -4,9 +4,8 @@ from __future__ import annotations import unittest from pathlib import Path import importlib.util +from . import PROJECT_ROOT - -PROJECT_ROOT = Path(__file__).resolve().parents[4] # /opt/src/infinito ROLE_DIR = PROJECT_ROOT / "roles" / "web-app-keycloak" MOD_PATH = ROLE_DIR / "module_utils" / "kcadm_json.py" diff --git a/tests/unit/utils/__init__.py b/tests/unit/utils/__init__.py index 8b13789179..78e3cae448 100644 --- a/tests/unit/utils/__init__.py +++ b/tests/unit/utils/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/unit/utils/cache/test_applications.py b/tests/unit/utils/cache/test_applications.py index 4d309eff31..65ec71b062 100644 --- a/tests/unit/utils/cache/test_applications.py +++ b/tests/unit/utils/cache/test_applications.py @@ -19,6 +19,7 @@ from pathlib import Path from utils.cache import _reset_cache_for_tests from utils.cache import applications as cache_apps +from . import PROJECT_ROOT def _write(path: Path, content: str) -> None: @@ -183,9 +184,8 @@ class TestApplicationsImportableWithoutAnsible(unittest.TestCase): def test_module_imports_and_get_variants_callable_without_ansible(self): import subprocess - from pathlib import Path - repo_root = Path(__file__).resolve().parents[4] + repo_root = PROJECT_ROOT snippet = ( "import sys\n" "sys.path.insert(0, %r)\n" diff --git a/tests/unit/utils/cache/test_data.py b/tests/unit/utils/cache/test_data.py index 037a657be8..1e88a91c7b 100644 --- a/tests/unit/utils/cache/test_data.py +++ b/tests/unit/utils/cache/test_data.py @@ -492,9 +492,8 @@ def _run_in_ansible_blocked_subprocess(snippet: str): """ import subprocess import sys - from pathlib import Path - repo_root = Path(__file__).resolve().parents[4] + repo_root = PROJECT_ROOT preamble = ( "import sys\n" "sys.path.insert(0, %r)\n" diff --git a/tests/unit/utils/cache/test_domains.py b/tests/unit/utils/cache/test_domains.py index e9e0a1b9d2..9bf22868a3 100644 --- a/tests/unit/utils/cache/test_domains.py +++ b/tests/unit/utils/cache/test_domains.py @@ -18,6 +18,7 @@ from unittest.mock import patch from utils.cache import _reset_cache_for_tests from utils.cache import domains as cache_domains +from . import PROJECT_ROOT def _write(path: Path, content: str) -> None: @@ -152,9 +153,8 @@ class TestImportableWithoutAnsible(unittest.TestCase): def test_module_imports_without_ansible(self): import subprocess - from pathlib import Path - repo_root = Path(__file__).resolve().parents[4] + repo_root = PROJECT_ROOT snippet = ( "import sys\n" "sys.path.insert(0, %r)\n" diff --git a/tests/unit/utils/test_invokable.py b/tests/unit/utils/test_invokable.py index 051bea6f8c..d7914d410f 100644 --- a/tests/unit/utils/test_invokable.py +++ b/tests/unit/utils/test_invokable.py @@ -51,7 +51,7 @@ class TestInvokable(TestCase): shutil.rmtree(self.tmp) def test_list_invokable_app_ids(self) -> None: - with patch.object(inv, "_repo_root", return_value=self.tmp): + with patch.object(inv, "PROJECT_ROOT", self.tmp): with patch.object( inv, "_get_invokable_paths", @@ -65,7 +65,7 @@ class TestInvokable(TestCase): ) def test_list_invokables_by_type(self) -> None: - with patch.object(inv, "_repo_root", return_value=self.tmp): + with patch.object(inv, "PROJECT_ROOT", self.tmp): with patch.object( inv, "_get_invokable_paths", @@ -91,7 +91,7 @@ class TestInvokable(TestCase): with (rd / "vars" / "main.yml").open("w", encoding="utf-8") as f: yaml.safe_dump({}, f) - with patch.object(inv, "_repo_root", return_value=self.tmp): + with patch.object(inv, "PROJECT_ROOT", self.tmp): with patch.object( inv, "_get_invokable_paths", @@ -102,7 +102,7 @@ class TestInvokable(TestCase): self.assertNotIn("web-app-oauth2-proxy", grouped["server"]) def test_types_from_group_names(self) -> None: - with patch.object(inv, "_repo_root", return_value=self.tmp): + with patch.object(inv, "PROJECT_ROOT", self.tmp): with patch.object( inv, "_get_invokable_paths", diff --git a/tests/unit/utils/test_templating.py b/tests/unit/utils/test_templating.py index 3215ce6844..199a54ab55 100644 --- a/tests/unit/utils/test_templating.py +++ b/tests/unit/utils/test_templating.py @@ -1,22 +1,15 @@ # tests/unit/utils/test_templating.py import os import unittest -from pathlib import Path from unittest.mock import patch from ansible.errors import AnsibleError - - -def _repo_root() -> Path: - # __file__ = tests/unit/utils/test_templating.py - return Path(__file__).resolve().parents[3] - +from . import PROJECT_ROOT # Ensure repo root is importable so `utils.*` resolves in all runners import sys # noqa: E402 -sys.path.insert(0, str(_repo_root())) # noqa: E402 - +sys.path.insert(0, str(PROJECT_ROOT)) # noqa: E402 from utils.templating import ( # noqa: E402 render_ansible_strict, diff --git a/utils/__init__.py b/utils/__init__.py index 8b13789179..9f4a6a3289 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[1] diff --git a/utils/invokable.py b/utils/invokable.py index cd25177ad4..12ddddccaa 100644 --- a/utils/invokable.py +++ b/utils/invokable.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Iterable, Set from utils.cache.yaml import load_yaml_any +from . import PROJECT_ROOT @dataclass(frozen=True) @@ -36,13 +37,8 @@ DEFAULT_RULES: tuple[DeploymentTypeRule, ...] = ( ) -def _repo_root() -> Path: - # utils/invokable.py -> utils -> repo root - return Path(__file__).resolve().parents[1] - - def _roles_dir() -> Path: - return _repo_root() / "roles" + return PROJECT_ROOT / "roles" def _categories_file() -> Path: diff --git a/utils/roles/__init__.py b/utils/roles/__init__.py index e69de29bb2..a11dbb1745 100644 --- a/utils/roles/__init__.py +++ b/utils/roles/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[2] diff --git a/utils/roles/meta_lookup.py b/utils/roles/meta_lookup.py index a6366a5c9c..0af9fb0d13 100644 --- a/utils/roles/meta_lookup.py +++ b/utils/roles/meta_lookup.py @@ -29,6 +29,7 @@ import yaml from utils.cache.yaml import load_yaml_any from utils.entity_name_utils import get_entity_name +from . import PROJECT_ROOT PathLike = Union[str, Path] @@ -130,7 +131,7 @@ def _resolve_role(role: PathLike, role_name: Optional[str]) -> tuple[Path, str]: else: # Treat as a role name relative to /roles. The repo root is # two levels above this module: utils/roles/meta_lookup.py -> repo. - repo_root = Path(__file__).resolve().parents[2] + repo_root = PROJECT_ROOT role_dir = repo_root / "roles" / str(role) name = role_name or role_dir.name return role_dir, name diff --git a/utils/roles/resource_validation.py b/utils/roles/resource_validation.py index cb8d0bab5e..d4a588e579 100644 --- a/utils/roles/resource_validation.py +++ b/utils/roles/resource_validation.py @@ -7,15 +7,11 @@ from humanfriendly import parse_size from utils.annotations.message import warning from utils.cache.yaml import load_yaml_any from utils.entity_name_utils import get_entity_name - - -def _repo_root() -> Path: - # utils/roles/resource_validation.py -> repo root = parents[2] - return Path(__file__).resolve().parents[2] +from . import PROJECT_ROOT def _roles_root() -> Path: - return (_repo_root() / "roles").resolve() + return (PROJECT_ROOT / "roles").resolve() def _deep_get(dct: dict, path: list[str]) -> Any: diff --git a/utils/valid_deploy_id.py b/utils/valid_deploy_id.py index bcf6b31036..67d5dae1f7 100644 --- a/utils/valid_deploy_id.py +++ b/utils/valid_deploy_id.py @@ -5,10 +5,10 @@ Utility for validating deployment application IDs against defined roles and inve from __future__ import annotations import os -from pathlib import Path from utils.cache.yaml import load_yaml_any from plugins.filter.get_all_application_ids import get_all_application_ids +from . import PROJECT_ROOT class ValidDeployId: @@ -16,7 +16,7 @@ class ValidDeployId: """ Always resolve roles/ from the repository root, independent of CWD. """ - repo_root = Path(__file__).resolve().parents[1] + repo_root = PROJECT_ROOT roles_dir = repo_root / "roles" if not roles_dir.is_dir():