diff --git a/cli/deploy/dedicated/__init__.py b/cli/deploy/dedicated/__init__.py index 7efa36b560..6ebf8dcde3 100644 --- a/cli/deploy/dedicated/__init__.py +++ b/cli/deploy/dedicated/__init__.py @@ -1,6 +1,10 @@ from __future__ import annotations -from .command import main -from .modes import _parse_bool_literal +from pathlib import Path -__all__ = ["_parse_bool_literal", "main"] +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] + +from .command import main # noqa: E402 +from .modes import _parse_bool_literal # noqa: E402 + +__all__ = ["PROJECT_ROOT", "_parse_bool_literal", "main"] diff --git a/cli/deploy/dedicated/command.py b/cli/deploy/dedicated/command.py index de910637dd..511adaf9e7 100644 --- a/cli/deploy/dedicated/command.py +++ b/cli/deploy/dedicated/command.py @@ -200,7 +200,7 @@ def main(argv: list[str] | None = None) -> int: modes: dict[str, Any] = build_modes_from_args(modes_spec, args) run_ansible_playbook( - repo_root=PROJECT_ROOT, + repo_root=str(PROJECT_ROOT), playbook_path=PLAYBOOK_PATH, inventory_validator_path=INVENTORY_VALIDATOR_PATH, inventory=args.inventory, diff --git a/cli/deploy/dedicated/paths.py b/cli/deploy/dedicated/paths.py index a5c4db7066..aefaeacd24 100644 --- a/cli/deploy/dedicated/paths.py +++ b/cli/deploy/dedicated/paths.py @@ -1,26 +1,10 @@ from __future__ import annotations -import os -from pathlib import Path +from . import PROJECT_ROOT - -def _resolve_repo_root() -> str: - """ - Resolve repository root based on this file location. - - Current file: /cli/deploy/dedicated/paths.py - """ - here = os.path.realpath(__file__) - dedicated_dir = str(Path(here).parent) # .../cli/deploy/dedicated - deploy_dir = str(Path(dedicated_dir).parent) # .../cli/deploy - cli_root = str(Path(deploy_dir).parent) # .../cli - return str(Path(cli_root).parent) # .../ - - -PROJECT_ROOT: str = _resolve_repo_root() -CLI_ROOT: str = str(Path(PROJECT_ROOT) / "cli") -PLAYBOOK_PATH: str = str(Path(PROJECT_ROOT) / "playbook.yml") -MODES_FILE: str = str(Path(PROJECT_ROOT) / "group_vars" / "all" / "01_modes.yml") +CLI_ROOT: str = str(PROJECT_ROOT / "cli") +PLAYBOOK_PATH: str = str(PROJECT_ROOT / "playbook.yml") +MODES_FILE: str = str(PROJECT_ROOT / "group_vars" / "all" / "01_modes.yml") INVENTORY_VALIDATOR_PATH: str = str( - Path(PROJECT_ROOT) / "cli" / "validate" / "inventory" / "__main__.py" + PROJECT_ROOT / "cli" / "validate" / "inventory" / "__main__.py" ) diff --git a/cli/fix/move_unnecessary_dependencies/__main__.py b/cli/fix/move_unnecessary_dependencies/__main__.py index d1cf37ed33..33249b932b 100755 --- a/cli/fix/move_unnecessary_dependencies/__main__.py +++ b/cli/fix/move_unnecessary_dependencies/__main__.py @@ -113,9 +113,8 @@ def main() -> None: ) parser.add_argument( "--project-root", - default=str( - Path(__file__).resolve().parents[3] - ), # nocheck: project-root-import CLI default arg + # nocheck: project-root-import CLI default arg + default=str(Path(__file__).resolve().parents[3]), help="Path to project root (default: two levels up from this script).", ) parser.add_argument( diff --git a/cli/meta/j2/compiler/__main__.py b/cli/meta/j2/compiler/__main__.py index 65703f7bff..a6979b40f5 100755 --- a/cli/meta/j2/compiler/__main__.py +++ b/cli/meta/j2/compiler/__main__.py @@ -4,9 +4,7 @@ import re import sys from pathlib import Path -from . import PROJECT_ROOT as _PROJECT_ROOT_PATH - -PROJECT_ROOT = str(_PROJECT_ROOT_PATH) +from . import PROJECT_ROOT INCLUDE_RE = re.compile(r"^(\s*)\{%\s*include\s*['\"]([^'\"]+)['\"]\s*%\}") @@ -24,7 +22,7 @@ def expand_includes(rel_path, seen=None): raise RuntimeError(f"Circular include detected: {rp}") seen.add(rp) - abs_path = str(Path(PROJECT_ROOT) / rp) + abs_path = str(PROJECT_ROOT / rp) if not Path(abs_path).is_file(): raise FileNotFoundError(f"Template not found: {rp}") diff --git a/tests/integration/filters/test_defined.py b/tests/integration/filters/test_defined.py index c2c3a95fbe..33a0a605c2 100644 --- a/tests/integration/filters/test_defined.py +++ b/tests/integration/filters/test_defined.py @@ -6,8 +6,6 @@ from pathlib import Path from utils.cache.files import iter_project_files, read_text -PROJECT_ROOT = str(Path(str(Path(str(Path(__file__).parent)) / "../../../")).resolve()) - # EXCLUDES tests/ by default; keeps True to require real usage sites EXCLUDE_TESTS = True diff --git a/tests/integration/filters/test_usage.py b/tests/integration/filters/test_usage.py index d77524b004..36fbe3667d 100644 --- a/tests/integration/filters/test_usage.py +++ b/tests/integration/filters/test_usage.py @@ -6,8 +6,6 @@ from pathlib import Path from utils.cache.files import iter_project_files, read_text -PROJECT_ROOT = str(Path(str(Path(str(Path(__file__).parent)) / "../../../")).resolve()) - SEARCH_EXTS = (".yml", ".yaml", ".j2", ".jinja2", ".tmpl", ".py") diff --git a/tests/integration/oauth2_oidc/__init__.py b/tests/integration/oauth2_oidc/__init__.py index e69de29bb2..78e3cae448 100644 --- a/tests/integration/oauth2_oidc/__init__.py +++ b/tests/integration/oauth2_oidc/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[3] diff --git a/tests/integration/oauth2_oidc/rbac/__init__.py b/tests/integration/oauth2_oidc/rbac/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/integration/oauth2_oidc/rbac/__init__.py +++ b/tests/integration/oauth2_oidc/rbac/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/integration/oauth2_oidc/rbac/test_group_path_role_declarations.py b/tests/integration/oauth2_oidc/rbac/test_group_path_role_declarations.py index 7ecfb4d1a2..2bd3eed2f0 100644 --- a/tests/integration/oauth2_oidc/rbac/test_group_path_role_declarations.py +++ b/tests/integration/oauth2_oidc/rbac/test_group_path_role_declarations.py @@ -49,9 +49,9 @@ from pathlib import Path import yaml -HERE = str(Path(str(Path(__file__).resolve())).parent) -PROJECT_ROOT = str(Path(str(Path(HERE) / ".." / ".." / ".." / "..")).resolve()) -ROLES_DIR = str(Path(PROJECT_ROOT) / "roles") +from . import PROJECT_ROOT + +ROLES_DIR = str(PROJECT_ROOT / "roles") IMPLICIT_ADMIN_ROLE = "administrator" diff --git a/tests/integration/oauth2_oidc/rbac/test_path_join_guard.py b/tests/integration/oauth2_oidc/rbac/test_path_join_guard.py index 384d24e74f..cb45d24bd0 100644 --- a/tests/integration/oauth2_oidc/rbac/test_path_join_guard.py +++ b/tests/integration/oauth2_oidc/rbac/test_path_join_guard.py @@ -19,8 +19,7 @@ import re import unittest from pathlib import Path -HERE = str(Path(str(Path(__file__).resolve())).parent) -PROJECT_ROOT = str(Path(str(Path(HERE) / ".." / ".." / ".." / "..")).resolve()) +from . import PROJECT_ROOT PATH_JOIN_PATTERN = re.compile( r"\[\s*RBAC\.GROUP\.NAME\s*,\s*.+?\]\s*\|\s*path_join", diff --git a/tests/integration/oauth2_oidc/test_allowed_groups_slash_normalization.py b/tests/integration/oauth2_oidc/test_allowed_groups_slash_normalization.py index 885e36a328..053b3f8fb1 100644 --- a/tests/integration/oauth2_oidc/test_allowed_groups_slash_normalization.py +++ b/tests/integration/oauth2_oidc/test_allowed_groups_slash_normalization.py @@ -30,10 +30,10 @@ import re import unittest from pathlib import Path -HERE = str(Path(str(Path(__file__).resolve())).parent) -PROJECT_ROOT = str(Path(str(Path(HERE) / ".." / ".." / "..")).resolve()) +from . import PROJECT_ROOT + TEMPLATE_PATH = str( - Path(PROJECT_ROOT) + PROJECT_ROOT / "roles" / "web-app-oauth2-proxy" / "templates" diff --git a/tests/integration/roles/test_playwright_env_service_flags.py b/tests/integration/roles/test_playwright_env_service_flags.py index 423e5a1eed..20709a9795 100644 --- a/tests/integration/roles/test_playwright_env_service_flags.py +++ b/tests/integration/roles/test_playwright_env_service_flags.py @@ -14,10 +14,9 @@ import re import unittest from pathlib import Path -HERE = str(Path(str(Path(__file__).resolve())).parent) -PROJECT_ROOT = str(Path(str(Path(HERE) / ".." / ".." / "..")).resolve()) +from . import PROJECT_ROOT -ROLES_DIR = str(Path(PROJECT_ROOT) / "roles") +ROLES_DIR = str(PROJECT_ROOT / "roles") SERVICE_FLAG_LINE = re.compile( r"^([A-Z][A-Z0-9_]+)_SERVICE_ENABLED\s*=\s*(.*)$", diff --git a/tests/lint/ansible/test_run_once_tags.py b/tests/lint/ansible/test_run_once_tags.py index 37d49b3780..f8f13118f1 100644 --- a/tests/lint/ansible/test_run_once_tags.py +++ b/tests/lint/ansible/test_run_once_tags.py @@ -7,9 +7,10 @@ from pathlib import Path from utils.annotations.suppress import is_suppressed_anywhere from utils.cache.files import read_text -PROJECT_ROOT = str(Path(str(Path(str(Path(__file__).parent)) / "../../../")).resolve()) -ROLES_DIR = str(Path(PROJECT_ROOT) / "roles") -ROOT_TASKS_DIR = str(Path(PROJECT_ROOT) / "tasks") +from . import PROJECT_ROOT + +ROLES_DIR = str(PROJECT_ROOT / "roles") +ROOT_TASKS_DIR = str(PROJECT_ROOT / "tasks") def is_under_root_tasks(fpath): @@ -31,7 +32,7 @@ def find_role_includes(roles_dir): fpath = str(Path(dirpath) / fname) # Skip any files under the root-level tasks/ directory if str(Path(fpath).resolve()).startswith( - str(Path(str(Path(roles_dir) / ".." / "tasks")).resolve()) + os.sep + str(Path(ROOT_TASKS_DIR).resolve()) + os.sep ): continue diff --git a/tests/lint/repository/test_project_root_import.py b/tests/lint/repository/test_project_root_import.py index bade16a784..ac54b92421 100644 --- a/tests/lint/repository/test_project_root_import.py +++ b/tests/lint/repository/test_project_root_import.py @@ -1,6 +1,6 @@ """Lint that pins ``PROJECT_ROOT`` to a single canonical source. -Two rules are enforced: +Three rules are enforced: 1. Every Python file in the repo (``*.py``) MUST NOT compute the project root locally. The forbidden patterns are: @@ -18,7 +18,14 @@ Two rules are enforced: ``from import PROJECT_ROOT``) so a future move of the file does not silently break a hard-coded ``parents[N]`` index. -2. Every ``__init__.py`` that DOES define ``PROJECT_ROOT`` MUST point +2. ``PROJECT_ROOT`` itself MUST only be assigned inside an + ``__init__.py``. A ``PROJECT_ROOT = …`` line anywhere else (test + modules, ``__main__.py`` shims, helper modules, …) fails the lint + even when the right-hand side does not climb ``parents[N]`` — + re-binding the constant locally defeats the single-source guarantee + rule 1 was added to enforce. + +3. Every ``__init__.py`` that DOES define ``PROJECT_ROOT`` MUST point at a directory containing ``pyproject.toml``. A wrong ``parents[N]`` index turns the constant into a silent footgun; the lint catches it the moment the index drifts off the repo root. @@ -75,6 +82,18 @@ _FORBIDDEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( ), ) +# A top-level ``PROJECT_ROOT`` (or ``PROJECT_ROOT: ``) assignment. +# Allowed only inside ``__init__.py``; everywhere else the constant +# MUST be imported via ``from . import PROJECT_ROOT`` (or +# ``from import PROJECT_ROOT``). +# +# The regex anchors at the start of a (stripped) line so neither +# ``_PROJECT_ROOT`` (a private re-binding) nor a regex source string +# mentioning ``PROJECT_ROOT`` triggers it. The negative ``(?!=)`` +# lookahead after ``=`` keeps a bare comparison (``PROJECT_ROOT == …``) +# from matching. +_PROJECT_ROOT_ASSIGN_RE = re.compile(r"^\s*PROJECT_ROOT(?:\s*:\s*[^=]+?)?\s*=(?!=)") + # The legitimate `PROJECT_ROOT = …` definition shape inside an # ``__init__.py``. The integrity check (does the resolved path carry # `pyproject.toml`?) only fires when this exact shape is seen, so a @@ -175,6 +194,21 @@ def _scan_file(path: Path) -> list[str]: continue failures.append(f"{path}:{lineno}: {label}") + # `PROJECT_ROOT = …` is allowed only inside an ``__init__.py``. + # Anywhere else the constant MUST be imported from the package. + if ( + not is_init + and _PROJECT_ROOT_ASSIGN_RE.match(line) + and not is_suppressed_at( + raw_lines, lineno, SUPPRESS_RULE, mode="same-or-above" + ) + ): + failures.append( + f"{path}:{lineno}: `PROJECT_ROOT` assignment outside " + f"`__init__.py` (import via `from . import PROJECT_ROOT` " + f"or `from import PROJECT_ROOT` instead)" + ) + return failures diff --git a/tests/unit/cli/build/inventory/__init__.py b/tests/unit/cli/build/inventory/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/cli/build/inventory/__init__.py +++ b/tests/unit/cli/build/inventory/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[5] diff --git a/tests/unit/cli/build/inventory/test_full.py b/tests/unit/cli/build/inventory/test_full.py index 98d45cfb4c..8ef1abb394 100644 --- a/tests/unit/cli/build/inventory/test_full.py +++ b/tests/unit/cli/build/inventory/test_full.py @@ -1,15 +1,13 @@ from __future__ import annotations -import os import sys import unittest +from . import PROJECT_ROOT + # Ensure repo root is importable (so `import cli...` works in all runners) -PROJECT_ROOT = os.path.abspath( - os.path.join(os.path.dirname(__file__), "../../../../../") -) -if PROJECT_ROOT not in sys.path: - sys.path.insert(0, PROJECT_ROOT) +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) from cli.build.inventory.full import command as full # noqa: E402 diff --git a/tests/unit/cli/deploy/dedicated/test_paths.py b/tests/unit/cli/deploy/dedicated/test_paths.py index 2c5b7c7228..216dbc12a9 100644 --- a/tests/unit/cli/deploy/dedicated/test_paths.py +++ b/tests/unit/cli/deploy/dedicated/test_paths.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import unittest from pathlib import Path @@ -8,15 +7,9 @@ from cli.deploy.dedicated import paths class TestPaths(unittest.TestCase): - def test_repo_root_resolution_matches_expected_parents(self): - # paths.py is: /cli/deploy/dedicated/paths.py - here = os.path.realpath(paths.__file__) - dedicated_dir = str(Path(here).parent) - deploy_dir = str(Path(dedicated_dir).parent) - cli_dir = str(Path(deploy_dir).parent) - expected_repo_root = str(Path(cli_dir).parent) - - self.assertEqual(paths.PROJECT_ROOT, expected_repo_root) + def test_project_root_resolves_to_repo(self): + self.assertTrue(Path(paths.PROJECT_ROOT).is_dir()) + self.assertTrue((Path(paths.PROJECT_ROOT) / "pyproject.toml").is_file()) def test_derived_paths_are_consistent(self): self.assertEqual(paths.CLI_ROOT, str(Path(paths.PROJECT_ROOT) / "cli")) diff --git a/tests/unit/plugins/filter/__init__.py b/tests/unit/plugins/filter/__init__.py index 8b13789179..88f288ec1f 100644 --- a/tests/unit/plugins/filter/__init__.py +++ b/tests/unit/plugins/filter/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/unit/plugins/filter/test_domain_tools.py b/tests/unit/plugins/filter/test_domain_tools.py index ad7e793074..2f0ab6c0d8 100644 --- a/tests/unit/plugins/filter/test_domain_tools.py +++ b/tests/unit/plugins/filter/test_domain_tools.py @@ -1,16 +1,15 @@ import sys import unittest -from pathlib import Path + +from . import PROJECT_ROOT # Make project root importable so "plugins.filter.domain_tools" works no matter where tests run from -THIS_DIR = str(Path(str(Path(__file__).resolve())).parent) -PROJECT_ROOT = str(Path(str(Path(THIS_DIR) / ".." / ".." / ".." / "..")).resolve()) -if PROJECT_ROOT not in sys.path: - sys.path.insert(0, PROJECT_ROOT) +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) -from ansible.errors import AnsibleFilterError # noqa: E402 +from ansible.errors import AnsibleFilterError -from plugins.filter.domain_tools import FilterModule, to_zone # noqa: E402 +from plugins.filter.domain_tools import FilterModule, to_zone class TestDomainTools(unittest.TestCase): diff --git a/tests/unit/roles/test-e2e-playwright/__init__.py b/tests/unit/roles/test-e2e-playwright/__init__.py index 8b13789179..88f288ec1f 100644 --- a/tests/unit/roles/test-e2e-playwright/__init__.py +++ b/tests/unit/roles/test-e2e-playwright/__init__.py @@ -1 +1,3 @@ +from pathlib import Path +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/unit/roles/test-e2e-playwright/test_service_gating_helper.py b/tests/unit/roles/test-e2e-playwright/test_service_gating_helper.py index ca441cbe4a..08d241d6cf 100644 --- a/tests/unit/roles/test-e2e-playwright/test_service_gating_helper.py +++ b/tests/unit/roles/test-e2e-playwright/test_service_gating_helper.py @@ -16,10 +16,10 @@ import textwrap import unittest from pathlib import Path -HERE = str(Path(str(Path(__file__).resolve())).parent) -PROJECT_ROOT = str(Path(str(Path(HERE) / ".." / ".." / ".." / "..")).resolve()) +from . import PROJECT_ROOT + HELPER_PATH = str( - Path(PROJECT_ROOT) / "roles" / "test-e2e-playwright" / "files" / "service-gating.js" + PROJECT_ROOT / "roles" / "test-e2e-playwright" / "files" / "service-gating.js" ) STUB_PKG_JSON = json.dumps({"name": "@playwright/test", "main": "index.js"}) diff --git a/tests/unit/roles/web-svc-logout/filter_plugins/__init__.py b/tests/unit/roles/web-svc-logout/filter_plugins/__init__.py index 8b13789179..8ee16d7567 100644 --- a/tests/unit/roles/web-svc-logout/filter_plugins/__init__.py +++ b/tests/unit/roles/web-svc-logout/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-svc-logout/filter_plugins/test_domain_filters.py b/tests/unit/roles/web-svc-logout/filter_plugins/test_domain_filters.py index 0a8cd548a8..7451b776f9 100644 --- a/tests/unit/roles/web-svc-logout/filter_plugins/test_domain_filters.py +++ b/tests/unit/roles/web-svc-logout/filter_plugins/test_domain_filters.py @@ -2,21 +2,12 @@ import importlib.util import unittest -from pathlib import Path -# Directory of this test file: .../tests/unit/roles/web-svc-logout/filter_plugins -THIS_DIR = str(Path(__file__).parent) - -# Compute the repo root by going up five levels: tests → unit → roles → web-svc-logout → filter_plugins → repo root -PROJECT_ROOT = str(Path(str(Path(THIS_DIR) / "../../../../..")).resolve()) +from . import PROJECT_ROOT # Path to the actual plugin under roles/web-svc-logout/filter_plugins DOMAIN_FILTERS_PATH = str( - Path(PROJECT_ROOT) - / "roles" - / "web-svc-logout" - / "filter_plugins" - / "domain_filters.py" + PROJECT_ROOT / "roles" / "web-svc-logout" / "filter_plugins" / "domain_filters.py" ) # Dynamically load the domain_filters module diff --git a/tests/unit/utils/roles/__init__.py b/tests/unit/utils/roles/__init__.py index e69de29bb2..88f288ec1f 100644 --- a/tests/unit/utils/roles/__init__.py +++ b/tests/unit/utils/roles/__init__.py @@ -0,0 +1,3 @@ +from pathlib import Path + +PROJECT_ROOT: Path = Path(__file__).resolve().parents[4] diff --git a/tests/unit/utils/roles/test_dependency_resolver.py b/tests/unit/utils/roles/test_dependency_resolver.py index 0b7a40fb88..f66e8f43e8 100644 --- a/tests/unit/utils/roles/test_dependency_resolver.py +++ b/tests/unit/utils/roles/test_dependency_resolver.py @@ -5,11 +5,12 @@ import unittest from pathlib import Path from textwrap import dedent -PROJECT_ROOT = str(Path(str(Path(str(Path(__file__).parent)) / "../../..")).resolve()) -if PROJECT_ROOT not in sys.path: - sys.path.insert(0, PROJECT_ROOT) +from . import PROJECT_ROOT -from utils.roles.dependency_resolver import RoleDependencyResolver # noqa: E402 +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from utils.roles.dependency_resolver import RoleDependencyResolver def write(path: str, content: str):