"""Overlay / custom-test / install-steps discovery + precedence (Phase 1d, plan §2.5, DG4/DG5). The generic is the default for each lifecycle op; a recipe's `test_.py` OVERRIDES it. Sources, in precedence order (machine-docs/DECISIONS.md): lifecycle op (install/upgrade/backup/restore) — exactly ONE assertion file runs: repo-local tests/test_.py (upstream-authoritative, wins same-name collisions) > cc-ci tests//test_.py > generic tests/_generic/test_.py <- always present; the floor custom (non-lifecycle) test_*.py — ALL run, additively, from BOTH locations (opt-in). install-steps hook — install_steps.sh: repo-local > cc-ci, or none. Repo-local = the recipe repo's own tests/ dir, snapshotted after fetch (it survives abra re-checking-out the recipe to a version tag — see the run orchestrator). """ from __future__ import annotations import glob import os LIFECYCLE_OPS = ("install", "upgrade", "backup", "restore") ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) GENERIC_DIR = os.path.join(ROOT, "tests", "_generic") def cc_ci_dir(recipe: str) -> str: return os.path.join(ROOT, "tests", recipe) def resolve_op(recipe: str, op: str, repo_local_dir: str | None) -> tuple[str, str]: """Return (source, path) for the single assertion file to run for `op`: source in {"repo-local","cc-ci","generic"}. The generic file is the floor and always exists.""" fname = f"test_{op}.py" if repo_local_dir: p = os.path.join(repo_local_dir, fname) if os.path.isfile(p): return ("repo-local", p) p = os.path.join(cc_ci_dir(recipe), fname) if os.path.isfile(p): return ("cc-ci", p) return ("generic", os.path.join(GENERIC_DIR, fname)) def custom_tests(recipe: str, repo_local_dir: str | None) -> list[tuple[str, str]]: """All non-lifecycle test_*.py from cc-ci's tests// and the recipe's repo-local tests/. These have no generic equivalent and run only when present (opt-in), additively from both.""" lifecycle_names = {f"test_{op}.py" for op in LIFECYCLE_OPS} found: list[tuple[str, str]] = [] for source, d in (("cc-ci", cc_ci_dir(recipe)), ("repo-local", repo_local_dir)): if not d or not os.path.isdir(d): continue for p in sorted(glob.glob(os.path.join(d, "test_*.py"))): if os.path.basename(p) not in lifecycle_names: found.append((source, p)) return found def install_steps(recipe: str, repo_local_dir: str | None) -> tuple[str, str] | None: """The custom install-steps hook (install_steps.sh) for a recipe, or None. repo-local > cc-ci.""" if repo_local_dir: p = os.path.join(repo_local_dir, "install_steps.sh") if os.path.isfile(p): return ("repo-local", p) p = os.path.join(cc_ci_dir(recipe), "install_steps.sh") if os.path.isfile(p): return ("cc-ci", p) return None