diff --git a/bin/pyauto-heart b/bin/pyauto-heart index 3ed765b..4354ac6 100755 --- a/bin/pyauto-heart +++ b/bin/pyauto-heart @@ -28,6 +28,7 @@ SUBCOMMAND_ORDER=( "# Release validation (ingest-and-judge only; never dispatches a build)" validate "# Deep checks (on-demand; too slow for the tick)" + smoke verify_install "# URL hygiene (monitoring only — does not gate releases)" url_check @@ -45,6 +46,7 @@ declare -A SHORT_DESC=( [readiness]="Print the release-readiness verdict (green/yellow/red + score)" [dashboard]="The unified health board (--oneline/--md/--html/--json); reads cache, no tick" [validate]="Ingest release-validation artifacts into validation_report.json" + [smoke]="Prepare isolated local environments and run workspace smoke suites" [logs]="Tail the daemon log" [fix]="Bundle context for a specific topic and emit a Claude command" [verify_install]="Deep install-readiness: pip & conda install-path checks (slow)" @@ -368,6 +370,52 @@ cmd_verify_install() { --report-json "$HEART_STATE_DIR/verify_install.json" "$@" } +help_smoke() { cat </dev/null 2>&1; then + runner_python="python3.12" + else + runner_python="python3" + fi + fi + mkdir -p "$HEART_STATE_DIR" + exec env PYTHONPATH="$HEART_HOME" "$runner_python" -m heart.smoke "$@" +} + help_url_check() { cat < subprocess.CompletedProcess[str]: + return subprocess.run( + [str(part) for part in command], + cwd=str(cwd) if cwd else None, + env=dict(env) if env is not None else None, + check=True, + text=True, + capture_output=capture_output, + ) + + +def _python_identity(python: str) -> dict[str, str]: + result = _run( + [ + python, + "-c", + "import json,platform,sys; " + "print(json.dumps({'executable':sys.executable," + "'version':platform.python_version()}))", + ], + capture_output=True, + ) + return json.loads(result.stdout) + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def environment_fingerprint( + organism_root: Path, + spec: WorkspaceSpec, + python_identity: Mapping[str, str], +) -> dict: + """Return the complete, serialisable cache contract for one workspace.""" + workspace = organism_root / spec.directory + watched = [workspace / ".github" / "scripts" / "smoke_install.sh"] + watched.extend(organism_root / repo / "pyproject.toml" for repo in spec.chain) + files = { + str(path.relative_to(organism_root)): _sha256(path) + for path in watched + if path.is_file() + } + return { + "schema": FINGERPRINT_SCHEMA, + "workspace": spec.key, + "chain": list(spec.chain), + "python": dict(python_identity), + "platform": platform.platform(), + "files": files, + } + + +def fingerprint_digest(fingerprint: Mapping) -> str: + encoded = json.dumps(fingerprint, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode()).hexdigest() + + +def _environment_python(environment: Path) -> Path: + folder = "Scripts" if os.name == "nt" else "bin" + executable = "python.exe" if os.name == "nt" else "python" + return environment / folder / executable + + +def _environment_bin(environment: Path) -> Path: + return environment / ("Scripts" if os.name == "nt" else "bin") + + +def environment_path(cache_root: Path, spec: WorkspaceSpec, identity: Mapping) -> Path: + major_minor = ".".join(str(identity["version"]).split(".")[:2]) + return cache_root / spec.key / f"py{major_minor}" + + +def _read_marker(environment: Path) -> dict | None: + marker = environment / MARKER_NAME + try: + return json.loads(marker.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return None + + +def cache_matches(environment: Path, fingerprint: Mapping) -> bool: + marker = _read_marker(environment) + return bool( + _environment_python(environment).is_file() + and marker + and marker.get("digest") == fingerprint_digest(fingerprint) + ) + + +def _installer_environment(environment: Path, python_version: str) -> dict[str, str]: + env = os.environ.copy() + env.pop("PYTHONPATH", None) + for name in tuple(env): + if name.startswith("PYAUTO_") and name not in {"PYAUTO_ROOT"}: + env.pop(name) + env.update( + { + "PATH": os.pathsep.join( + (str(_environment_bin(environment)), env.get("PATH", "")) + ), + "VIRTUAL_ENV": str(environment), + "PYTHONNOUSERSITE": "1", + "PYTHON_VERSION": ".".join(python_version.split(".")[:2]), + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + } + ) + return env + + +def runtime_environment( + environment: Path, + organism_root: Path, + spec: WorkspaceSpec, + state_root: Path, +) -> dict[str, str]: + """Build a leak-resistant child environment using live local source.""" + env = os.environ.copy() + for name in tuple(env): + if name.startswith("PYAUTO_"): + env.pop(name) + source_paths = [str(organism_root / repo) for repo in spec.chain] + source_paths.append(str(organism_root / "PyAutoHands" / "autohands")) + cache_dir = state_root / "smoke-runtime" / spec.key + (cache_dir / "numba").mkdir(parents=True, exist_ok=True) + (cache_dir / "matplotlib").mkdir(parents=True, exist_ok=True) + env.update( + { + "PATH": os.pathsep.join( + (str(_environment_bin(environment)), env.get("PATH", "")) + ), + "VIRTUAL_ENV": str(environment), + "PYTHONNOUSERSITE": "1", + # Deliberately replace, rather than extend, ambient PYTHONPATH. + "PYTHONPATH": os.pathsep.join(source_paths), + "PYAUTO_ROOT": str(organism_root), + "JAX_ENABLE_X64": "True", + "NUMBA_CACHE_DIR": str(cache_dir / "numba"), + "MPLCONFIGDIR": str(cache_dir / "matplotlib"), + } + ) + return env + + +def _optional_local_targets(organism_root: Path, chain: Iterable[str]) -> list[str]: + targets: list[str] = [] + for repo in chain: + pyproject = organism_root / repo / "pyproject.toml" + if not pyproject.is_file(): + continue + with pyproject.open("rb") as stream: + data = tomllib.load(stream) + extras = data.get("project", {}).get("optional-dependencies", {}) + if "optional" in extras: + targets.append(f"./{repo}[optional]") + return targets + + +def _install_environment( + environment: Path, + organism_root: Path, + spec: WorkspaceSpec, + identity: Mapping[str, str], +) -> None: + python = _environment_python(environment) + env = _installer_environment(environment, identity["version"]) + _run( + [python, "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"], + env=env, + ) + _run([python, "-m", "pip", "install", "pyyaml"], env=env) + + workspace = organism_root / spec.directory + installer = workspace / ".github" / "scripts" / "smoke_install.sh" + if installer.is_file(): + _run(["bash", installer], cwd=organism_root, env=env) + return + + # Legacy workspaces have no CI epilogue yet. Install their local chain and + # discover the conventional `optional` extras from package metadata; Heart + # does not carry a second list of third-party packages. + local_targets = [f"./{repo}" for repo in spec.chain] + _run([python, "-m", "pip", "install", *local_targets], cwd=organism_root, env=env) + optional_targets = _optional_local_targets(organism_root, spec.chain) + if optional_targets: + _run( + [python, "-m", "pip", "install", *optional_targets], + cwd=organism_root, + env=env, + ) + notebooks = workspace / "smoke_notebooks.txt" + if notebooks.is_file() and notebooks.read_text().strip(): + _run( + [ + python, + "-m", + "pip", + "install", + "jupyter", + "nbconvert", + "ipynb-py-convert", + ], + env=env, + ) + + +def _preflight( + environment: Path, + organism_root: Path, + spec: WorkspaceSpec, + state_root: Path, +) -> None: + """Prove dependencies and executables resolve inside the intended env.""" + # Do not resolve this symlink: venv/bin/python commonly points at the base + # interpreter, and collapsing it would make every following `-m` command + # escape the environment we are trying to prove. + python = _environment_python(environment).absolute() + env = runtime_environment(environment, organism_root, spec, state_root) + executable = _run( + [ + python, + "-c", + "import pathlib,sys; print(pathlib.Path(sys.executable).absolute())", + ], + env=env, + capture_output=True, + ).stdout.strip() + if Path(executable) != python: + raise SmokeEnvironmentError( + f"interpreter leak: expected {python}, subprocess used {executable}" + ) + _run([python, "-m", "pip", "check"], env=env, capture_output=True) + + expected = { + IMPORT_NAMES[repo]: str((organism_root / repo).resolve()) + for repo in spec.chain + if repo in IMPORT_NAMES + } + probe = ( + "import importlib,json,pathlib,sys; expected=json.loads(sys.argv[1]); " + "bad=[]; " + "[(bad.append(f'{name} -> {path}') if not pathlib.Path(path).resolve().is_relative_to(pathlib.Path(root)) else None) " + "for name,root in expected.items() " + "for path in [importlib.import_module(name).__file__]]; " + "print(json.dumps({'imports':expected,'errors':bad})); " + "raise SystemExit(bool(bad))" + ) + _run([python, "-c", probe, json.dumps(expected)], env=env, capture_output=True) + + notebook_list = organism_root / spec.directory / "smoke_notebooks.txt" + if notebook_list.is_file() and notebook_list.read_text().strip(): + jupyter = _environment_bin(environment) / "jupyter" + if not jupyter.is_file(): + raise SmokeEnvironmentError(f"jupyter is missing from {environment}") + kernels = json.loads( + _run( + [jupyter, "kernelspec", "list", "--json"], + env=env, + capture_output=True, + ).stdout + ).get("kernelspecs", {}) + kernel = kernels.get("python3", {}).get("spec", {}).get("argv", []) + kernel_python = None + if kernel: + candidate = Path(kernel[0]) + if candidate.is_absolute(): + kernel_python = candidate + else: + found = shutil.which(kernel[0], path=env["PATH"]) + kernel_python = Path(found) if found else None + if kernel_python is None or kernel_python.absolute() != python: + raise SmokeEnvironmentError( + f"python3 Jupyter kernel does not use {python}: {kernel or 'missing'}" + ) + + +def _safe_remove_environment(path: Path, cache_root: Path) -> None: + resolved = path.resolve() + root = cache_root.resolve() + if resolved == root or not resolved.is_relative_to(root): + raise SmokeEnvironmentError(f"refusing to remove unsafe cache path: {path}") + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +@contextlib.contextmanager +def _environment_lock(target: Path): + """Serialise preparation without placing a lock inside the replaceable env.""" + target.parent.mkdir(parents=True, exist_ok=True) + lock_path = target.parent / f".{target.name}.lock" + with lock_path.open("a+") as stream: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + + +def _prune_abandoned_stages(target: Path, cache_root: Path) -> None: + """Clean staging directories left by the pre-v1, relocatable-venv prototype.""" + for stale in target.parent.glob(f".{target.name}.build-*"): + _safe_remove_environment(stale, cache_root) + + +def prepare_environment( + organism_root: Path, + state_root: Path, + spec: WorkspaceSpec, + *, + python: str = sys.executable, + rebuild: bool = False, +) -> tuple[Path, bool]: + identity = _python_identity(python) + cache_root = state_root / "smoke-envs" + target = environment_path(cache_root, spec, identity) + with _environment_lock(target): + _prune_abandoned_stages(target, cache_root) + fingerprint = environment_fingerprint(organism_root, spec, identity) + if not rebuild and cache_matches(target, fingerprint): + try: + _preflight(target, organism_root, spec, state_root) + return target, False + except (SmokeEnvironmentError, subprocess.CalledProcessError, OSError): + print(f"[{spec.key}] cached environment failed preflight; rebuilding") + + # A venv is not relocatable: console-script shebangs embed its creation + # path. Build at the final path under a lock, retaining the previous + # complete environment as a rollback backup until preflight succeeds. + backup = target.with_name(f".{target.name}.old-{uuid.uuid4().hex}") + if target.exists() or target.is_symlink(): + target.rename(backup) + try: + _run([python, "-m", "venv", target]) + _install_environment(target, organism_root, spec, identity) + _preflight(target, organism_root, spec, state_root) + marker = { + "digest": fingerprint_digest(fingerprint), + "fingerprint": fingerprint, + } + (target / MARKER_NAME).write_text( + json.dumps(marker, indent=2, sort_keys=True) + "\n" + ) + except BaseException: + if target.exists() or target.is_symlink(): + _safe_remove_environment(target, cache_root) + if backup.exists() or backup.is_symlink(): + backup.rename(target) + raise + if backup.exists() or backup.is_symlink(): + _safe_remove_environment(backup, cache_root) + return target, True + + +def _wipe_output(workspace: Path) -> None: + output = workspace / "output" + if not output.is_dir() or output.is_symlink(): + return + for child in output.iterdir(): + if child.is_symlink() or child.is_file(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child) + + +def _load_nonempty_lines(path: Path) -> list[str]: + if not path.is_file(): + return [] + return [ + line.strip() + for line in path.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def _run_legacy_workspace( + python: Path, + workspace: Path, + organism_root: Path, + env: Mapping[str, str], +) -> int: + """Run an allowlist for the one legacy workspace without a runner.""" + hands_path = organism_root / "PyAutoHands" / "autohands" + sys.path.insert(0, str(hands_path)) + try: + from env_config import build_env_for_script, load_env_config + from build_util import should_skip + import yaml + finally: + sys.path.pop(0) + + profile_path = workspace / "config" / "build" / "profile_smoke.yaml" + config = load_env_config(profile_path) if profile_path.is_file() else None + no_run_path = workspace / "config" / "build" / "no_run.yaml" + no_run = yaml.safe_load(no_run_path.read_text()) if no_run_path.is_file() else [] + if isinstance(no_run, dict): + no_run = next(iter(no_run.values()), []) + args = shlex.split((config or {}).get("args_default", "")) + failures = 0 + scripts = _load_nonempty_lines(workspace / "smoke_tests.txt") + for entry in scripts: + script = workspace / entry + if not script.is_file(): + script = workspace / "scripts" / entry + if should_skip(script, no_run or []): + print(f"[SKIP] {entry}") + continue + if not script.is_file(): + print(f"[MISSING] {entry}") + failures += 1 + continue + child_env = build_env_for_script(Path(entry), config) or dict(env) + # build_env_for_script starts from os.environ; restore the isolation + # variables that identify this prepared smoke environment. + child_env.update(env) + result = subprocess.run( + [str(python), str(script), *args], cwd=workspace, env=child_env + ) + label = "PASS" if result.returncode == 0 else f"FAIL {result.returncode}" + print(f"[{label}] {entry}") + failures += result.returncode != 0 + print( + f"=== Smoke test summary: {len(scripts) - failures}/{len(scripts)} passed ===" + ) + return 1 if failures else 0 + + +def run_workspace( + environment: Path, + organism_root: Path, + state_root: Path, + spec: WorkspaceSpec, +) -> int: + workspace = organism_root / spec.directory + _wipe_output(workspace) + env = runtime_environment(environment, organism_root, spec, state_root) + python = _environment_python(environment) + runner = workspace / ".github" / "scripts" / "run_smoke.py" + if runner.is_file(): + return subprocess.run( + [str(python), str(runner)], cwd=workspace, env=env + ).returncode + return _run_legacy_workspace(python, workspace, organism_root, env) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Prepare isolated workspace smoke environments and run their suites." + ) + parser.add_argument( + "workspaces", + nargs="*", + choices=tuple(WORKSPACES), + help="workspace keys (default: all)", + ) + parser.add_argument( + "--rebuild", action="store_true", help="rebuild selected environments" + ) + parser.add_argument( + "--prepare-only", + action="store_true", + help="prepare and preflight without running scripts", + ) + parser.add_argument( + "--python", default=sys.executable, help="base Python interpreter" + ) + parser.add_argument( + "--root", + type=Path, + default=Path( + os.environ.get("PYAUTO_ROOT", Path(__file__).resolve().parents[2]) + ), + help="PyAutoLabs organism root", + ) + parser.add_argument( + "--state-dir", + type=Path, + default=Path(os.environ.get("HEART_STATE_DIR", Path.home() / ".pyauto-heart")), + help="Heart state/cache directory", + ) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + root = args.root.resolve() + selected = args.workspaces or list(WORKSPACES) + failures = 0 + for key in selected: + spec = WORKSPACES[key] + workspace = root / spec.directory + if not workspace.is_dir(): + print(f"[{key}] missing workspace: {workspace}", file=sys.stderr) + failures += 1 + continue + try: + environment, built = prepare_environment( + root, + args.state_dir.resolve(), + spec, + python=args.python, + rebuild=args.rebuild, + ) + print(f"[{key}] {'built' if built else 'reused'} {environment}") + if not args.prepare_only: + failures += ( + run_workspace(environment, root, args.state_dir.resolve(), spec) + != 0 + ) + except (SmokeEnvironmentError, subprocess.CalledProcessError, OSError) as exc: + print( + f"[{key}] environment FAILED before smoke execution: {exc}", + file=sys.stderr, + ) + failures += 1 + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/smoke_test/SKILL.md b/skills/smoke_test/SKILL.md index 408997e..372ab64 100644 --- a/skills/smoke_test/SKILL.md +++ b/skills/smoke_test/SKILL.md @@ -17,9 +17,9 @@ model: PyAutoBrain `skills/WORKFLOW.md`. ## Usage ``` -$smoke-test # run all six workspaces (default) -$smoke-test autofit # run only autofit_workspace (only when explicitly requested) -$smoke-test autogalaxy autolens # run specific workspaces (only when explicitly requested) +$smoke-test # pyauto-heart smoke (all six; default) +$smoke-test autofit # pyauto-heart smoke autofit +$smoke-test autogalaxy autolens # pyauto-heart smoke autogalaxy autolens ``` In Claude, invoke the same skill as `/smoke_test`. @@ -47,21 +47,31 @@ lists `.ipynb` notebooks under `notebooks/`. Notebook + env-var semantics: dependency chain, so never assume only one is affected. Run a subset only when the user explicitly passes workspace names. -### 2. Load env config + wipe stale output +### 2. Prepare and preflight isolated environments -For each workspace, read `config/build/profile_smoke.yaml` to build the per-script env -prefix (`defaults` minus matching `overrides` `unset`s, plus optional -`args_default`). Before launching, wipe `/output/*` (glob — keep -the tracked `output/` dir). Detail: [`reference.md`](reference.md) → -"Environment config" and "Why wipe output". +Run `pyauto-heart smoke` with the selected workspace keys. Do not invoke the +workspace runners directly and do not repair the active shell by installing +packages into it. The command creates one cached environment per workspace from +its CI `smoke_install.sh`, invalidates it when Python/install metadata changes, +and preflights interpreter, package and Jupyter-kernel ownership before any +science script starts. Detail: [`reference.md`](reference.md) → "Isolated smoke +environments". -### 3. Run the scripts (parallel) +Useful diagnostic forms: -Read `smoke_tests.txt`; skip entries listed in `config/build/no_run.yaml` -(`SKIPPED`). Resolve each path (workspace root, then `scripts/`, else `MISSING`) -and run with its env prefix, **in parallel** via background processes + `wait`. -Exact path-resolution + parallel-launch recipe: [`reference.md`](reference.md) → -"Running the scripts". +```bash +pyauto-heart smoke autogalaxy --prepare-only # environment proof only +pyauto-heart smoke autogalaxy --rebuild # discard/rebuild that cache +``` + +### 3. Run the workspace-owned suites + +The command wipes stale `output/*` immediately before execution, then invokes +each workspace's `.github/scripts/run_smoke.py` with the prepared interpreter +and live local source on an isolated `PYTHONPATH`. The workspace runner owns +script/notebook discovery, profile resolution, skips, timeouts and reporting, +so local and CI semantics stay aligned. Heart has a compatibility runner for a +legacy workspace that has not acquired those two CI entry points yet. ### 4. Track + report @@ -87,11 +97,14 @@ cache dir can't be created. - Env vars, their exceptions, and `args_default` live in each workspace's `config/build/profile_smoke.yaml`; the skip list in `config/build/no_run.yaml`. Edit those files — don't hardcode env vars here. +- Dependencies live in library `pyproject.toml` metadata and workspace + `.github/scripts/smoke_install.sh` files. Never add a package-specific repair + list to this skill; the cache fingerprint automatically follows those files. - `smoke_tests.txt` files live in each workspace root. - Toggling `PYAUTO_SMALL_DATASETS` requires deleting `/dataset/` (auto- simulation only re-creates missing datasets). `euclid_strong_lens_modeling_pipeline` does **not** use `PYAUTO_SMALL_DATASETS` — it tests against real Euclid VIS imaging. -- **Execution environments** (see WORKFLOW.md): in a web-github / ci-only session - with no local tree, clone the workspace + library repos into the working - directory, export `PYTHONPATH`/`NUMBA_CACHE_DIR`/`MPLCONFIGDIR`, and run the same - steps. Detail: [`reference.md`](reference.md) → "Execution environments". +- **Execution environments** (see WORKFLOW.md): in a web-github / ci-only + session with no local tree, clone the workspace + library repos into one + organism root and pass it with `pyauto-heart smoke --root `. Detail: + [`reference.md`](reference.md) → "Execution environments". diff --git a/skills/smoke_test/reference.md b/skills/smoke_test/reference.md index 637dbca..0def518 100644 --- a/skills/smoke_test/reference.md +++ b/skills/smoke_test/reference.md @@ -14,6 +14,38 @@ wasn't refreshed by `$pre-build` (`/pre_build` in Claude). Whole-workspace regeneration stays `generate.py`'s job — smoke only regenerates the one failing notebook. +## Isolated smoke environments + +`pyauto-heart smoke` is the local environment and execution entry point. It +does not trust the shell that launched it: + +- Every workspace has a separate virtual environment under + `$HEART_STATE_DIR/smoke-envs//py/`. This separation is + required because workspace contracts can carry different JAX bounds. +- The workspace-owned `.github/scripts/smoke_install.sh` is executed with the + environment's `pip` first on `PATH`, exactly like Heart's reusable CI smoke + workflow. A legacy workspace without an epilogue gets its local chain and + conventional `optional` extras from the libraries' `pyproject.toml` files; + Heart never carries a duplicate list of third-party requirements. +- The fingerprint includes Python identity, the install epilogue and every + chain library's `pyproject.toml`. Any change rebuilds at the environment's + fixed path under a per-environment lock (virtualenv entry-point shebangs make + finished environments non-relocatable). The previous complete environment is + retained as a rollback backup until preflight succeeds, so a failed rebuild + restores it intact. +- Runtime `PYTHONPATH` is replaced with the selected local library roots plus + `PyAutoHands/autohands`; it is not extended from the ambient shell. Current + source edits therefore win over cached site-packages without allowing an + unrelated checkout to leak in. +- Before scripts begin, the command runs `pip check`, imports each local library + and verifies its file lives under the expected checkout. Workspaces with + notebooks must also have `jupyter` inside the environment and a `python3` + kernelspec whose executable is that environment's Python. + +Use `--prepare-only` to diagnose the environment without touching workspace +outputs or running scripts. Use `--rebuild` only when explicitly forcing a +fresh resolution; ordinary metadata changes rebuild automatically. + ## Environment config Each workspace's `config/build/profile_smoke.yaml` has: @@ -40,22 +72,13 @@ model schema evolved since the cached run, the header no longer matches ## Running the scripts -Read `smoke_tests.txt` (paths relative to the workspace root). Skip entries -matching `config/build/no_run.yaml` (`SKIPPED`). Resolve each path: - -1. `/` exists → use it (root-level scripts, explicit - `scripts/`). -2. else `/scripts/` exists → use it (legacy bare names). -3. else `MISSING`, continue. - -Run in parallel (scripts have no interdependencies — no `start_here.py` ordering): - -```bash -cd - python > /tmp/smoke__.log 2>&1 & -``` - -`wait` to collect exit codes after launching all. +`pyauto-heart smoke` invokes the workspace-owned +`.github/scripts/run_smoke.py`. Do not reproduce its discovery, ordering, +timeouts, skip matching or notebook behavior in an agent shell: those details +have changed before and the checked-in runner is the CI contract. The sole +legacy fallback reads `smoke_tests.txt`, resolves root-relative then +`scripts/`-relative entries, uses PyAutoHands's canonical environment resolver, +and honours `config/build/no_run.yaml`. ## Issue comment @@ -111,8 +134,8 @@ silently if `mkdir` fails. ## Execution environments In a web-github / ci-only session (no local tree), clone the workspace repos and -the library repos for `PYTHONPATH` into the working directory, then run the same -steps: +the library repos into one working directory, then point Heart at that organism +root: ```bash WORK_DIR="$(pwd)" @@ -123,10 +146,10 @@ done for lib in PyAutoNerves PyAutoFit PyAutoArray PyAutoGalaxy PyAutoLens; do [ -d "$WORK_DIR/$lib" ] || git clone "https://github.com/PyAutoLabs/$lib.git" "$WORK_DIR/$lib" done -export PYTHONPATH="$WORK_DIR/PyAutoNerves:$WORK_DIR/PyAutoFit:$WORK_DIR/PyAutoArray:$WORK_DIR/PyAutoGalaxy:$WORK_DIR/PyAutoLens:$PYTHONPATH" -export NUMBA_CACHE_DIR=/tmp/numba_cache MPLCONFIGDIR=/tmp/matplotlib +pyauto-heart smoke --root "$WORK_DIR" ``` -Use `$WORK_DIR/` as each workspace root; post results to the issue as -normal. This is the same validation with a different repo source — not a separate -"mobile mode". +The command constructs `PYTHONPATH`, writable caches and virtual environments; +do not export ambient substitutes. Post results to the issue as normal. This is +the same validation with a different repo source — not a separate "mobile +mode". diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..c84df81 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from heart import smoke + + +def make_tree(tmp_path: Path, spec: smoke.WorkspaceSpec) -> Path: + root = tmp_path / "PyAutoLabs" + workspace = root / spec.directory + (workspace / ".github" / "scripts").mkdir(parents=True) + (workspace / ".github" / "scripts" / "smoke_install.sh").write_text( + "#!/usr/bin/env bash\nset -e\n" + ) + for repo in spec.chain: + repo_root = root / repo + repo_root.mkdir(parents=True) + (repo_root / "pyproject.toml").write_text( + f'[project]\nname = "{repo.lower()}"\nversion = "1"\n' + ) + (root / "PyAutoHands" / "autohands").mkdir(parents=True) + return root + + +def completed(stdout: str = "") -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess([], 0, stdout=stdout, stderr="") + + +def test_fingerprint_changes_with_installer_and_dependency_metadata(tmp_path): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoArray",)) + root = make_tree(tmp_path, spec) + identity = {"executable": "/usr/bin/python3", "version": "3.12.8"} + + original = smoke.fingerprint_digest( + smoke.environment_fingerprint(root, spec, identity) + ) + installer = root / spec.directory / ".github" / "scripts" / "smoke_install.sh" + installer.write_text(installer.read_text() + "pip install example\n") + installer_changed = smoke.fingerprint_digest( + smoke.environment_fingerprint(root, spec, identity) + ) + (root / "PyAutoArray" / "pyproject.toml").write_text( + '[project]\nname = "autoarray"\nversion = "2"\n' + ) + metadata_changed = smoke.fingerprint_digest( + smoke.environment_fingerprint(root, spec, identity) + ) + + assert original != installer_changed + assert installer_changed != metadata_changed + + +def test_runtime_environment_replaces_ambient_python_and_pyauto_state( + tmp_path, monkeypatch +): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoFit", "PyAutoArray")) + root = make_tree(tmp_path, spec) + environment = tmp_path / "environment" + smoke._environment_bin(environment).mkdir(parents=True) + monkeypatch.setenv("PYTHONPATH", "/ambient/leak") + monkeypatch.setenv("PYAUTO_TEST_MODE", "unexpected") + + env = smoke.runtime_environment(environment, root, spec, tmp_path / "state") + + assert "/ambient/leak" not in env["PYTHONPATH"] + assert env["PYTHONPATH"].split(os.pathsep) == [ + str(root / "PyAutoFit"), + str(root / "PyAutoArray"), + str(root / "PyAutoHands" / "autohands"), + ] + assert "PYAUTO_TEST_MODE" not in env + assert env["PATH"].split(os.pathsep)[0] == str(smoke._environment_bin(environment)) + assert Path(env["NUMBA_CACHE_DIR"]).is_relative_to(tmp_path / "state") + + +def test_prepare_reuses_cache_then_rebuilds_after_metadata_change( + tmp_path, monkeypatch +): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoArray",)) + root = make_tree(tmp_path, spec) + state = tmp_path / "state" + identity = {"executable": "/fake/python", "version": "3.12.8"} + installs: list[Path] = [] + preflights: list[Path] = [] + + monkeypatch.setattr(smoke, "_python_identity", lambda _: identity) + + def fake_run(command, **kwargs): + if "venv" in [str(part) for part in command]: + environment = Path(command[-1]) + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + return completed() + + monkeypatch.setattr(smoke, "_run", fake_run) + monkeypatch.setattr( + smoke, + "_install_environment", + lambda environment, *_: installs.append(environment), + ) + monkeypatch.setattr( + smoke, + "_preflight", + lambda environment, *_: preflights.append(environment), + ) + + first, first_built = smoke.prepare_environment(root, state, spec) + second, second_built = smoke.prepare_environment(root, state, spec) + (root / "PyAutoArray" / "pyproject.toml").write_text( + '[project]\nname = "autoarray"\nversion = "2"\n' + ) + third, third_built = smoke.prepare_environment(root, state, spec) + + assert first == second == third + assert (first_built, second_built, third_built) == (True, False, True) + assert len(installs) == 2 + assert len(preflights) == 3 + assert smoke.cache_matches( + third, smoke.environment_fingerprint(root, spec, identity) + ) + + +def test_failed_rebuild_restores_previous_complete_environment(tmp_path, monkeypatch): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoArray",)) + root = make_tree(tmp_path, spec) + state = tmp_path / "state" + identity = {"executable": "/fake/python", "version": "3.12.8"} + monkeypatch.setattr(smoke, "_python_identity", lambda _: identity) + + def fake_run(command, **kwargs): + if "venv" in [str(part) for part in command]: + environment = Path(command[-1]) + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + return completed() + + monkeypatch.setattr(smoke, "_run", fake_run) + monkeypatch.setattr(smoke, "_install_environment", lambda *_: None) + monkeypatch.setattr(smoke, "_preflight", lambda *_: None) + target, _ = smoke.prepare_environment(root, state, spec) + old_marker = (target / smoke.MARKER_NAME).read_text() + (root / "PyAutoArray" / "pyproject.toml").write_text( + '[project]\nname = "autoarray"\nversion = "2"\n' + ) + monkeypatch.setattr( + smoke, + "_install_environment", + lambda *_: (_ for _ in ()).throw(smoke.SmokeEnvironmentError("install failed")), + ) + + with pytest.raises(smoke.SmokeEnvironmentError, match="install failed"): + smoke.prepare_environment(root, state, spec) + + assert smoke._environment_python(target).is_file() + assert (target / smoke.MARKER_NAME).read_text() == old_marker + + +def test_workspace_installer_is_the_dependency_source_of_truth(tmp_path, monkeypatch): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoFit",)) + root = make_tree(tmp_path, spec) + environment = tmp_path / "environment" + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + calls = [] + monkeypatch.setattr( + smoke, + "_run", + lambda command, **kwargs: calls.append( + ([str(item) for item in command], kwargs) + ) + or completed(), + ) + + smoke._install_environment( + environment, + root, + spec, + {"executable": "/fake/python", "version": "3.12.8"}, + ) + + installer = root / spec.directory / ".github" / "scripts" / "smoke_install.sh" + assert any(command == ["bash", str(installer)] for command, _ in calls) + assert all("./PyAutoFit" not in command for command, _ in calls) + installer_call = next(kwargs for command, kwargs in calls if command[0] == "bash") + assert installer_call["cwd"] == root + assert installer_call["env"]["PYTHON_VERSION"] == "3.12" + + +def test_legacy_installer_derives_optional_extras_from_pyproject(tmp_path, monkeypatch): + spec = smoke.WorkspaceSpec("legacy", "legacy_workspace", ("PyAutoArray",)) + root = make_tree(tmp_path, spec) + (root / spec.directory / ".github" / "scripts" / "smoke_install.sh").unlink() + (root / "PyAutoArray" / "pyproject.toml").write_text(""" +[project] +name = "autoarray" +version = "1" + +[project.optional-dependencies] +optional = ["nufftax>=0.6"] +""".strip() + "\n") + environment = tmp_path / "environment" + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + commands = [] + monkeypatch.setattr( + smoke, + "_run", + lambda command, **kwargs: commands.append([str(item) for item in command]) + or completed(), + ) + + smoke._install_environment( + environment, + root, + spec, + {"executable": "/fake/python", "version": "3.12.8"}, + ) + + assert any("./PyAutoArray" in command for command in commands) + assert any("./PyAutoArray[optional]" in command for command in commands) + assert all("nufftax" not in command for command in commands) + + +def test_preflight_rejects_jupyter_kernel_from_another_interpreter( + tmp_path, monkeypatch +): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ()) + root = make_tree(tmp_path, spec) + (root / spec.directory / "smoke_notebooks.txt").write_text("intro.ipynb\n") + environment = tmp_path / "environment" + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + jupyter = smoke._environment_bin(environment) / "jupyter" + jupyter.write_text("") + + def fake_run(command, **kwargs): + words = [str(item) for item in command] + if words[-3:] == ["kernelspec", "list", "--json"]: + return completed( + json.dumps( + { + "kernelspecs": { + "python3": { + "spec": { + "argv": ["/usr/bin/python3", "-m", "ipykernel"] + } + } + } + } + ) + ) + if "pathlib.Path(sys.executable).absolute()" in " ".join(words): + return completed(str(python.absolute()) + "\n") + return completed("{}\n") + + monkeypatch.setattr(smoke, "_run", fake_run) + + with pytest.raises(smoke.SmokeEnvironmentError, match="Jupyter kernel"): + smoke._preflight(environment, root, spec, tmp_path / "state") + + +def test_preflight_keeps_venv_python_path_when_it_is_a_symlink(tmp_path, monkeypatch): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ()) + root = make_tree(tmp_path, spec) + environment = tmp_path / "environment" + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.symlink_to("/usr/bin/python3.12") + commands = [] + + def fake_run(command, **kwargs): + words = [str(item) for item in command] + commands.append(words) + if "pathlib.Path(sys.executable).absolute()" in " ".join(words): + return completed(str(python.absolute()) + "\n") + return completed("{}\n") + + monkeypatch.setattr(smoke, "_run", fake_run) + + smoke._preflight(environment, root, spec, tmp_path / "state") + + assert commands + assert all(command[0] == str(python.absolute()) for command in commands) + + +def test_preflight_accepts_relative_kernel_resolved_inside_environment( + tmp_path, monkeypatch +): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ()) + root = make_tree(tmp_path, spec) + (root / spec.directory / "smoke_notebooks.txt").write_text("intro.ipynb\n") + environment = tmp_path / "environment" + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + python.chmod(0o755) + (smoke._environment_bin(environment) / "jupyter").write_text("") + + def fake_run(command, **kwargs): + words = [str(item) for item in command] + if words[-3:] == ["kernelspec", "list", "--json"]: + return completed( + json.dumps( + { + "kernelspecs": { + "python3": {"spec": {"argv": ["python", "-m", "ipykernel"]}} + } + } + ) + ) + if "pathlib.Path(sys.executable).absolute()" in " ".join(words): + return completed(str(python.absolute()) + "\n") + return completed("{}\n") + + monkeypatch.setattr(smoke, "_run", fake_run) + + smoke._preflight(environment, root, spec, tmp_path / "state") + + +def test_safe_remove_refuses_paths_outside_smoke_cache(tmp_path): + cache = tmp_path / "state" / "smoke-envs" + outside = tmp_path / "do-not-delete" + outside.mkdir() + + with pytest.raises(smoke.SmokeEnvironmentError, match="unsafe cache path"): + smoke._safe_remove_environment(outside, cache) + + assert outside.is_dir() + + +def test_run_workspace_uses_prepared_python_and_isolated_environment( + tmp_path, monkeypatch +): + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoFit",)) + root = make_tree(tmp_path, spec) + runner = root / spec.directory / ".github" / "scripts" / "run_smoke.py" + runner.write_text("") + environment = tmp_path / "environment" + python = smoke._environment_python(environment) + python.parent.mkdir(parents=True) + python.write_text("") + calls = [] + monkeypatch.setenv("PYTHONPATH", "/ambient/leak") + monkeypatch.setattr(smoke, "_wipe_output", lambda _: None) + monkeypatch.setattr( + smoke.subprocess, + "run", + lambda command, **kwargs: calls.append((command, kwargs)) + or subprocess.CompletedProcess(command, 0), + ) + + result = smoke.run_workspace(environment, root, tmp_path / "state", spec) + + assert result == 0 + assert calls[0][0] == [str(python), str(runner)] + assert calls[0][1]["cwd"] == root / spec.directory + assert "/ambient/leak" not in calls[0][1]["env"]["PYTHONPATH"] + + +def test_cli_help_exposes_isolation_and_rebuild_contract(): + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + ["bash", str(root / "bin" / "pyauto-heart"), "help", "smoke"], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "separate, cached Python environment" in result.stdout + assert "--prepare-only" in result.stdout + assert "--rebuild" in result.stdout + + +def test_shell_wrapper_uses_explicit_smoke_python_before_module_import(): + root = Path(__file__).resolve().parents[1] + script = (root / "bin" / "pyauto-heart").read_text() + body = script[script.index("cmd_smoke()") : script.index("help_url_check()")] + + assert "--python) next_is_python=1" in body + assert 'exec env PYTHONPATH="$HEART_HOME" "$runner_python" -m heart.smoke' in body