From 6b1271aa05288d96ed3e2d62925c828fcf67790e Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 15 Sep 2026 20:40:43 +0100 Subject: [PATCH 1/2] feat(heart): check F becomes a Colab gate seeded from Google's manifest (#227) Check F emulated Colab with `pip install autolens jax` WITH dependencies, so its venv already held every package the real --no-deps bootstrap would miss; `corner` was absent on Colab on 2026-09-15 and no gate saw it. - heart/checks/colab_gate.py: `seed` builds a python3.12 venv holding only the packages Google's Colab manifest (googlecolab/backend-info) ships that the stack's resolved closure needs; `verify` walks declared requirements, AST-scans every third-party import in the installed libraries (guarded or not), imports each for real, constructs the tutorial searches, and FAILs on any unguarded miss. - verify_install.sh: check F runs seed -> verbatim setup cell -> verify -> notebook cell; with-deps stack install removed; COLAB_GATE_AUTONERVES_SRC overlay for witness runs; gate JSON nested under checks[F].colab_gate. - Vendored manifest snapshot, empty heart/config/colab_gate.yaml accepted_missing, 43 new tests, check F docs rewritten. Witness: PyPI as-is -> F|FAIL naming corner (74 s); PyAutoNerves#167 overlaid -> corner/blackjax cleared, five library-side unguarded imports remain (jax_zero_contour, zeus, colossus, hmf, mcp). Co-Authored-By: Claude Fable 5.1 --- bin/pyauto-heart | 9 +- docs/release_validation.md | 16 + health_agent/capabilities.yaml | 2 +- heart/checks/colab_gate.py | 1234 ++++++++++++++++++++ heart/checks/colab_pip_freeze.snapshot.txt | 649 ++++++++++ heart/checks/verify_install.sh | 251 +++- heart/config/colab_gate.yaml | 20 + skills/verify_install/verify_install.md | 50 +- tests/test_colab_gate.py | 659 +++++++++++ tests/test_verify_install_script.py | 196 +++- 10 files changed, 3029 insertions(+), 57 deletions(-) create mode 100644 heart/checks/colab_gate.py create mode 100644 heart/checks/colab_pip_freeze.snapshot.txt create mode 100644 heart/config/colab_gate.yaml create mode 100644 tests/test_colab_gate.py diff --git a/bin/pyauto-heart b/bin/pyauto-heart index 2d7fb4b..2447c9e 100755 --- a/bin/pyauto-heart +++ b/bin/pyauto-heart @@ -417,10 +417,13 @@ Checks: C conda install flow (python=3.12) + start_here.py + welcome.py D pip install "autolens[optional]" resolves and imports E pip install autolens== installs on Python 3.12 by explicit pin - F Colab bootstrap simulation + one real notebook cell + F Colab gate: a python3.12 venv holding Google's Colab package set, the + injected setup cell verbatim, an audit of every import and declared + dependency the --no-deps bootstrap left unmet, then one notebook cell -Checks B and E require explicit interpreters on PATH. Check B requires -python3.11, python3.12, and python3.13; Check E requires python3.12. Missing +Checks B, E and F require explicit interpreters on PATH. Check B requires +python3.11, python3.12, and python3.13; Check E requires python3.12, and Check F +requires python3.12 because that is the interpreter Colab runs. Missing required interpreters are FAIL. A passing --find-links run is development-only evidence and leaves readiness STALE; PyPI/TestPyPI evidence is required for the release gate. diff --git a/docs/release_validation.md b/docs/release_validation.md index 0f38802..e77f05e 100644 --- a/docs/release_validation.md +++ b/docs/release_validation.md @@ -121,6 +121,22 @@ Check B reuses that exact TestPyPI version: it must install and import on Python that this release holds the floor, because an unpinned install may select an older compatible one instead. +Check F is the Colab gate. It builds a `python3.12` venv holding the package +set Google's Colab actually ships — resolved from the `googlecolab/backend-info` +`pip-freeze.txt` manifest, fetched live and cached at +`$HEART_STATE_DIR/colab_pip_freeze.txt`, with a vendored snapshot as the last +fallback — and then runs the injected setup cell verbatim on top of it. Only the +part of the stack's with-deps closure that Colab also ships is pre-installed, so +the setup cell's real `pip install ... --no-deps` is observed doing what it does +on Colab. The gate then walks the installed libraries' declared requirements, +AST-scans every `import` in their source at any depth and imports each one for +real: an unguarded import of a module Colab will not have, a headline `autofit` +search that cannot be constructed, or a declared dependency that is both absent +and imported is a **FAIL**. Version conflicts, guarded imports and never-imported +gaps are WARNs carried in the report. Until 2026-09-15 Check F installed the +stack **with** dependencies before running the cell, so the bootstrap's misses +(`corner`, `optax`, `xxhash`, `blackjax`) were invisible to it and shipped. + Check B then requires the unpinned install to be refused as well. That is a separate guarantee, and it was not met until 2026-08-19: `pip install autolens` on 3.11 backtracked to `2026.7.29.1` and installed a stale, JAX-less stack diff --git a/health_agent/capabilities.yaml b/health_agent/capabilities.yaml index 0015a65..0df16d7 100644 --- a/health_agent/capabilities.yaml +++ b/health_agent/capabilities.yaml @@ -96,7 +96,7 @@ deep_checks: - id: verify_install impl: heart/checks/verify_install.sh cli: "pyauto-heart verify_install" - measures: "pip, conda, and Colab install-path checks A-F; Check B exact release succeeds on Python 3.12/3.13 and rejects on 3.11; Check E installs historical 2026.2.26.4 on Python 3.12 because its stack has no Python 3.13 dependency wheels" + measures: "pip, conda, and Colab install-path checks A-F; Check B exact release succeeds on Python 3.12/3.13 and rejects on 3.11; Check E installs historical 2026.2.26.4 on Python 3.12 because its stack has no Python 3.13 dependency wheels; Check F builds a python3.12 venv holding Google's Colab package set (googlecolab/backend-info manifest), runs the injected setup cell verbatim, and fails on any unguarded import or imported-but-declared dependency the --no-deps bootstrap leaves unmet" gate_role: "RED if last run ready==false; STALE if find-links-only, older than 14d, or never run" - id: url_check impl: heart/checks/url_check.sh diff --git a/heart/checks/colab_gate.py b/heart/checks/colab_gate.py new file mode 100644 index 0000000..4f65c89 --- /dev/null +++ b/heart/checks/colab_gate.py @@ -0,0 +1,1234 @@ +#!/usr/bin/env python3 +"""heart/checks/colab_gate.py — the Colab package-set gate behind verify_install check F. + +Check F used to *emulate* Colab by running ``pip install autolens jax`` WITH +dependencies and then running the injected setup cell on top. That install is +the bug the gate exists to remove: it leaves the venv holding every declared +dependency (corner, optax, xxhash, blackjax, ...) before the setup cell ever +runs, so the setup cell's real ``pip install ... --no-deps`` can never be seen +to miss anything. A notebook that dies on Colab at the first post-fit plot +still passed check F. + +This module rebuilds the environment from the package set Google actually ships +(``googlecolab/backend-info``'s ``pip-freeze.txt``) and then audits what the +``--no-deps`` bootstrap left behind. + +Two subcommands, both designed to run with the *simulated venv's* interpreter +(``"$venv/bin/python" colab_gate.py ...``) so ``importlib.metadata`` and the +import probe see that venv and nothing else: + +``seed`` + Runs BEFORE the setup cell. Resolves the with-deps closure of the PyAuto + stack with ``pip install --dry-run --report -``, intersects it with the + Colab manifest, and installs ONLY that intersection, at Colab's pinned + versions, ``--no-deps``. Everything in the closure that Colab does not ship + is deliberately left absent — that absence is what the gate measures. + +``verify`` + Runs AFTER the setup cell has done its real ``--no-deps`` bootstrap and + workspace clone. Walks the installed PyAuto distributions' declared + requirements, AST-scans every ``import`` in their source, probes each + third-party module for real, and constructs the four headline searches. + +Exit codes: 0 pass, 1 fail, 2 tool error. + +Dependencies: the standard library plus ``packaging`` (pip vendors it, but the +gate installs it into the venv explicitly). PyYAML is used for the optional +config file when importable and a minimal fallback parser covers its absence. +""" + +from __future__ import annotations + +import argparse +import ast +import datetime +import json +import re +import subprocess +import sys +import urllib.request +from pathlib import Path +from typing import Any, Callable, Iterable, Sequence + +MANIFEST_URL = ( + "https://raw.githubusercontent.com/googlecolab/backend-info/main/pip-freeze.txt" +) + +CHECKS_DIR = Path(__file__).resolve().parent +SNAPSHOT_PATH = CHECKS_DIR / "colab_pip_freeze.snapshot.txt" +CONFIG_PATH = CHECKS_DIR.parent / "config" / "colab_gate.yaml" + +#: The PyAuto distributions. Never seeded from Colab (Colab has none of them), +#: never import-probed (they are the thing under test), and the roots of the +#: requirement walk. +PYAUTO_PACKAGES = ("autonerves", "autofit", "autoarray", "autogalaxy", "autolens") + +#: Distributions whose import name is not derivable from the distribution name. +#: Used only to decide whether a *missing* distribution (so one with no local +#: metadata to read the mapping from) is one the libraries actually import. +DIST_MODULE_ALIASES = { + "scikit-learn": ["sklearn"], + "scikit-image": ["skimage"], + "pyyaml": ["yaml"], + "pillow": ["PIL"], + "opencv-python": ["cv2"], + "nautilus-sampler": ["nautilus"], + "timeout-decorator": ["timeout_decorator"], + "zeus-mcmc": ["zeus"], + "attrs": ["attr"], + "protobuf": ["google"], + "typing-extensions": ["typing_extensions"], + "beautifulsoup4": ["bs4"], + "python-dateutil": ["dateutil"], + "astropy-iers-data": ["astropy_iers_data"], +} + +SEARCH_CONSTRUCTORS = ("Emcee", "DynestyStatic", "Nautilus", "LBFGS") + + +# -------------------------------------------------------------------------- +# manifest +# -------------------------------------------------------------------------- + + +def normalise(name: str) -> str: + """PEP 503 normalisation: ``SQLAlchemy`` -> ``sqlalchemy``, ``_`` -> ``-``.""" + return re.sub(r"[-_.]+", "-", name.strip()).lower() + + +def parse_manifest(text: str) -> dict[str, str]: + """Parse a ``pip freeze`` manifest into ``{normalised name: version}``. + + Only ``name==version`` pins are kept. Direct references (``name @ url``), + VCS installs, editable installs, comments and blank lines are skipped — + none of them names a version the gate can install by pin. Extras are + stripped from the name. + """ + out: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.split(" #", 1)[0].strip() + if not line or line.startswith("#") or line.startswith("-"): + continue + if "@" in line or line.startswith(("git+", "hg+", "svn+", "bzr+")): + continue + if "==" not in line: + continue + name, _, version = line.partition("==") + name = name.split("[", 1)[0].strip() + version = version.strip() + if not name or not version: + continue + out[normalise(name)] = version + return out + + +def _fetch_live(url: str = MANIFEST_URL, timeout: float = 10.0) -> str: + with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 + return response.read().decode("utf-8", "replace") + + +def _snapshot_date(text: str) -> str | None: + match = re.search(r"fetched\s+(\d{4}-\d{2}-\d{2})", text) + return match.group(1) if match else None + + +def load_manifest( + cache_path: Path | None, + snapshot_path: Path | None = None, + fetcher: Callable[[], str] | None = None, +) -> tuple[dict[str, str], str, str | None, list[str]]: + """Load the Colab manifest, live -> cache -> vendored snapshot. + + Returns ``(packages, source, date, notes)`` where ``source`` is one of + ``live``, ``cache`` or ``snapshot``. A live fetch refreshes the cache so a + later offline run degrades to yesterday's real manifest rather than to the + vendored snapshot, which ages with the repo. + """ + snapshot_path = snapshot_path or SNAPSHOT_PATH + fetcher = fetcher or _fetch_live + notes: list[str] = [] + + try: + text = fetcher() + packages = parse_manifest(text) + if not packages: + raise ValueError("live manifest parsed to zero pins") + except Exception as exc: # network, DNS, proxy, HTTP error, empty body + notes.append(f"live fetch failed: {exc.__class__.__name__}: {exc}") + else: + if cache_path is not None: + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(text) + except OSError as exc: + notes.append(f"cache write failed: {exc}") + today = datetime.date.today().isoformat() + return packages, "live", today, notes + + if cache_path is not None and cache_path.is_file(): + try: + text = cache_path.read_text() + packages = parse_manifest(text) + if packages: + date = datetime.date.fromtimestamp( + cache_path.stat().st_mtime + ).isoformat() + return packages, "cache", date, notes + notes.append("cache parsed to zero pins") + except OSError as exc: + notes.append(f"cache read failed: {exc}") + + text = Path(snapshot_path).read_text() + return parse_manifest(text), "snapshot", _snapshot_date(text), notes + + +# -------------------------------------------------------------------------- +# pip +# -------------------------------------------------------------------------- + + +def _pip(args: Sequence[str], **kwargs: Any) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-m", "pip", *args], + capture_output=True, + text=True, + **kwargs, + ) + + +def parse_dry_run_report(stdout: str) -> dict[str, str]: + """Extract ``{normalised name: version}`` from a ``pip --report -`` payload. + + ``--quiet`` is not a guarantee of a clean stdout (warnings from the + resolver reach it), so the JSON document is located rather than assumed to + start at byte zero. + """ + start = stdout.find("{") + if start < 0: + raise ValueError("no JSON object in pip --report output") + report = json.loads(stdout[start:]) + out: dict[str, str] = {} + for item in report.get("install", []): + metadata = item.get("metadata") or {} + name = metadata.get("name") + version = metadata.get("version") + if name: + out[normalise(name)] = str(version or "") + return out + + +def resolve_closure(targets: Sequence[str], index_args: Sequence[str]) -> dict[str, str]: + """The full with-deps resolution of ``targets``, without installing it.""" + result = _pip( + [ + "install", + "--dry-run", + "--quiet", + "--report", + "-", + *index_args, + *targets, + ] + ) + if result.returncode != 0: + raise RuntimeError( + "pip install --dry-run failed:\n" + (result.stderr or result.stdout)[-2000:] + ) + return parse_dry_run_report(result.stdout) + + +def colab_intersection( + closure: Iterable[str], manifest: dict[str, str] +) -> list[str]: + """Closure names Colab also ships, PyAuto packages excluded, sorted.""" + pyauto = {normalise(p) for p in PYAUTO_PACKAGES} + return sorted( + name for name in set(closure) if name in manifest and name not in pyauto + ) + + +# -------------------------------------------------------------------------- +# requirement walk +# -------------------------------------------------------------------------- + + +def _marker_mentions_extra(marker: Any) -> bool: + return bool(re.search(r"\bextra\b", str(marker))) + + +def _installed_version(name: str) -> str | None: + from importlib import metadata + + try: + return metadata.version(name) + except metadata.PackageNotFoundError: + return None + + +def _requirements(dist_name: str) -> list[str]: + from importlib import metadata + + try: + return list(metadata.distribution(dist_name).requires or []) + except metadata.PackageNotFoundError: + return [] + + +def walk_requirements( + roots: Sequence[str], + manifest: dict[str, str], + installed_version: Callable[[str], str | None] = _installed_version, + requirements: Callable[[str], list[str]] = _requirements, + install: Callable[[list[str]], tuple[bool, str]] | None = None, + max_rounds: int = 8, +) -> dict[str, Any]: + """Walk declared requirements from ``roots``, healing what Colab provides. + + An unmet requirement Colab ships is installed at Colab's pinned version + (``--no-deps``) and the walk continues through it — that is what a user's + Colab session already has. An unmet requirement Colab does NOT ship is a + real hole in the ``--no-deps`` bootstrap and is recorded in + ``missing_declared``. An installed version outside a declared specifier is + a ``version_conflict`` (reported, never blocking: Colab pins what it pins). + """ + from packaging.requirements import InvalidRequirement, Requirement + + missing: dict[str, dict[str, Any]] = {} + conflicts: dict[str, dict[str, Any]] = {} + colab_installed: list[dict[str, str]] = [] + unparsed: list[str] = [] + seen: set[str] = set() + rounds = 0 + + queue = list(roots) + while queue and rounds < max_rounds: + rounds += 1 + to_install: list[str] = [] + pending: list[tuple[str, str]] = [] # (name, required_by) + next_queue: list[str] = [] + + while queue: + dist = queue.pop(0) + key = normalise(dist) + if key in seen: + continue + seen.add(key) + + for raw in requirements(dist): + try: + req = Requirement(raw) + except InvalidRequirement: + unparsed.append(f"{dist}: {raw}") + continue + if req.marker is not None: + if _marker_mentions_extra(req.marker): + continue + try: + if not req.marker.evaluate(): + continue + except Exception: + continue + + name = req.name + key_req = normalise(name) + version = installed_version(name) + if version is None: + if key_req in manifest: + pin = f"{name}=={manifest[key_req]}" + if pin not in to_install: + to_install.append(pin) + pending.append((name, dist)) + else: + entry = missing.setdefault( + key_req, + { + "name": name, + "required_by": [], + "specifier": str(req.specifier), + }, + ) + if dist not in entry["required_by"]: + entry["required_by"].append(dist) + continue + + if str(req.specifier) and version not in req.specifier: + entry = conflicts.setdefault( + key_req, + { + "name": name, + "installed": version, + "specifier": str(req.specifier), + "required_by": [], + }, + ) + if dist not in entry["required_by"]: + entry["required_by"].append(dist) + + if key_req not in seen: + next_queue.append(name) + + if to_install and install is not None: + ok, output = install(to_install) + # pip wrote new .dist-info dirs from a subprocess; importlib caches + # directory listings by mtime, so a same-second install can stay + # invisible to metadata lookups without this. + import importlib + + importlib.invalidate_caches() + for pin, (name, required_by) in zip(to_install, pending): + version = installed_version(name) + if version is None: + missing.setdefault( + normalise(name), + { + "name": name, + "required_by": [required_by], + "specifier": "", + "note": "Colab pin failed to install: " + + (output[-200:] if not ok else "(no metadata after install)"), + }, + ) + else: + colab_installed.append( + {"name": name, "version": version, "required_by": required_by} + ) + next_queue.append(name) + elif to_install: + # No installer wired (unit tests): record the intent, don't walk on. + for pin, (name, required_by) in zip(to_install, pending): + colab_installed.append( + {"name": name, "version": pin.split("==", 1)[1], + "required_by": required_by, "requested": True} + ) + + queue = next_queue + + return { + "rounds": rounds, + "missing_declared": sorted(missing.values(), key=lambda e: e["name"].lower()), + "version_conflict": sorted(conflicts.values(), key=lambda e: e["name"].lower()), + "colab_installed": colab_installed, + "unparsed_requirements": unparsed, + "walked": sorted(seen), + } + + +# -------------------------------------------------------------------------- +# AST import scan +# -------------------------------------------------------------------------- + + +def _handler_catches_import_error(handler: ast.ExceptHandler) -> bool: + caught = handler.type + if caught is None: # bare `except:` + return True + nodes = caught.elts if isinstance(caught, ast.Tuple) else [caught] + for node in nodes: + name = None + if isinstance(node, ast.Name): + name = node.id + elif isinstance(node, ast.Attribute): + name = node.attr + if name in {"ImportError", "ModuleNotFoundError", "Exception", "BaseException"}: + return True + return False + + +def scan_imports_source(source: str, relpath: str) -> list[tuple[str, int, bool]]: + """Every absolute import in ``source`` as ``(top-level name, line, guarded)``. + + Imports at any depth are collected — a dependency imported inside a + function is exactly the case that survives ``import autolens`` and only + detonates at the line that reaches it. ``guarded`` means the import sits in + the ``body`` of a ``try`` whose handlers catch ImportError (so the library + already has a fallback); the ``except``/``else``/``finally`` arms do not + count as guarded. + """ + try: + tree = ast.parse(source, filename=relpath) + except SyntaxError: + return [] + + found: list[tuple[str, int, bool]] = [] + + def record(name: str, lineno: int, guarded: bool) -> None: + top = name.split(".", 1)[0] + if top: + found.append((top, lineno, guarded)) + + def visit(node: ast.AST, guarded: bool) -> None: + if isinstance(node, ast.Import): + for alias in node.names: + record(alias.name, node.lineno, guarded) + return + if isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: + record(node.module, node.lineno, guarded) + return + if isinstance(node, ast.Try) or ( + hasattr(ast, "TryStar") and isinstance(node, getattr(ast, "TryStar")) + ): + body_guarded = guarded or any( + _handler_catches_import_error(h) for h in node.handlers + ) + for child in node.body: + visit(child, body_guarded) + for handler in node.handlers: + for child in handler.body: + visit(child, guarded) + for child in list(node.orelse) + list(node.finalbody): + visit(child, guarded) + return + for child in ast.iter_child_nodes(node): + visit(child, guarded) + + visit(tree, False) + return found + + +def scan_package_imports(roots: dict[str, Path]) -> dict[str, list[dict[str, Any]]]: + """AST-scan every ``.py`` under ``roots`` -> ``{module: [site, ...]}``. + + Standard-library modules, the PyAuto packages themselves and ``__future__`` + are dropped: none of them can be missing on Colab. + """ + excluded = set(sys.stdlib_module_names) | {"__future__"} + excluded |= {p for p in PYAUTO_PACKAGES} + sites: dict[str, list[dict[str, Any]]] = {} + + for package, root in sorted(roots.items()): + base = root.parent + for path in sorted(root.rglob("*.py")): + try: + source = path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + try: + relpath = str(path.relative_to(base)) + except ValueError: + relpath = str(path) + for name, lineno, guarded in scan_imports_source(source, relpath): + if name in excluded or name.startswith("_"): + continue + sites.setdefault(name, []).append( + {"file": relpath, "line": lineno, "guarded": guarded} + ) + return sites + + +# -------------------------------------------------------------------------- +# probes +# -------------------------------------------------------------------------- + +_PROBE_SOURCE = r""" +import json, sys +results = {} +for name in json.loads(sys.argv[1]): + try: + __import__(name) + except BaseException as exc: + results[name] = f"{exc.__class__.__name__}: {exc}" + else: + results[name] = None +sys.stdout.write("COLAB_GATE_PROBE " + json.dumps(results)) +""" + + +def _run_probe(names: Sequence[str], timeout: float) -> dict[str, str | None]: + result = subprocess.run( + [sys.executable, "-c", _PROBE_SOURCE, json.dumps(list(names))], + capture_output=True, + text=True, + timeout=timeout, + ) + marker = result.stdout.find("COLAB_GATE_PROBE ") + if marker < 0: + raise RuntimeError(result.stderr[-500:] or "probe produced no result") + return json.loads(result.stdout[marker + len("COLAB_GATE_PROBE ") :]) + + +def probe_imports(names: Sequence[str]) -> dict[str, str | None]: + """Import each name for real, in a subprocess. ``None`` means it imported. + + One batched subprocess keeps interpreter startup off the per-name bill; a + module that takes the whole process down with it (segfault, ``os._exit``) + falls back to one subprocess per name so a single bad actor cannot hide the + rest of the results. + """ + names = list(names) + if not names: + return {} + try: + return _run_probe(names, timeout=900) + except Exception: + pass + + results: dict[str, str | None] = {} + for name in names: + try: + results.update(_run_probe([name], timeout=180)) + except subprocess.TimeoutExpired: + results[name] = "TimeoutExpired: import did not finish in 180s" + except Exception as exc: + results[name] = f"{exc.__class__.__name__}: {exc}" + return results + + +_CONSTRUCTOR_SOURCE = r""" +import json, sys +results = {} +try: + import autofit as af +except BaseException as exc: + results["import autofit"] = f"{exc.__class__.__name__}: {exc}" +else: + for name in json.loads(sys.argv[1]): + try: + getattr(af, name)() + except BaseException as exc: + results[name] = f"{exc.__class__.__name__}: {exc}" + else: + results[name] = None +sys.stdout.write("COLAB_GATE_SEARCH " + json.dumps(results)) +""" + + +def probe_constructors( + names: Sequence[str] = SEARCH_CONSTRUCTORS, +) -> dict[str, str | None]: + """Construct the headline ``autofit`` searches; ``None`` means it built.""" + try: + result = subprocess.run( + [sys.executable, "-c", _CONSTRUCTOR_SOURCE, json.dumps(list(names))], + capture_output=True, + text=True, + timeout=900, + ) + except subprocess.TimeoutExpired: + return {"autofit searches": "TimeoutExpired: constructors did not finish"} + marker = result.stdout.find("COLAB_GATE_SEARCH ") + if marker < 0: + return { + "autofit searches": (result.stderr[-500:] or "no result from constructor probe") + } + return json.loads(result.stdout[marker + len("COLAB_GATE_SEARCH ") :]) + + +# -------------------------------------------------------------------------- +# config +# -------------------------------------------------------------------------- + + +def _parse_config_fallback(text: str) -> dict[str, Any]: + """Minimal reader for the one shape colab_gate.yaml is allowed to take. + + ``verify`` runs inside the simulated venv, which is not guaranteed to hold + PyYAML. The file is a list of ``name``/``reason`` mappings, which is small + enough to read without it. + """ + entries: list[dict[str, str]] = [] + in_list = False + for raw in text.splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line.strip(): + continue + if re.match(r"^accepted_missing\s*:", line): + in_list = True + continue + if not line.startswith((" ", "-")) and ":" in line: + in_list = False + continue + if not in_list: + continue + stripped = line.strip() + if stripped.startswith("- "): + entries.append({}) + stripped = stripped[2:].strip() + if ":" in stripped and entries: + key, _, value = stripped.partition(":") + entries[-1][key.strip()] = value.strip().strip("'\"") + return {"accepted_missing": [e for e in entries if e.get("name")]} + + +def load_config(path: Path | None) -> dict[str, Any]: + path = Path(path) if path else CONFIG_PATH + if not path.is_file(): + return {"accepted_missing": []} + text = path.read_text() + try: + import yaml + except ImportError: + return _parse_config_fallback(text) + data = yaml.safe_load(text) or {} + if not isinstance(data, dict): + return {"accepted_missing": []} + accepted = data.get("accepted_missing") or [] + return {"accepted_missing": [e for e in accepted if isinstance(e, dict) and e.get("name")]} + + +# -------------------------------------------------------------------------- +# verdict +# -------------------------------------------------------------------------- + + +def module_dist_candidates(module: str) -> set[str]: + """Distribution names a top-level module could be published under. + + The inverse of :data:`DIST_MODULE_ALIASES`, used to ask the Colab manifest + "does Colab ship the thing this import needs?" for a module that is not + installed (so has no metadata to read the mapping from). + """ + candidates = {normalise(module)} + for dist, modules in DIST_MODULE_ALIASES.items(): + if module in modules: + candidates.add(normalise(dist)) + return candidates + + +def dist_module_candidates(dist_name: str) -> set[str]: + key = normalise(dist_name) + candidates = {key, key.replace("-", "_"), dist_name, dist_name.lower()} + candidates.update(DIST_MODULE_ALIASES.get(key, [])) + return candidates + + +def verdict( + *, + missing_declared: list[dict[str, Any]], + version_conflict: list[dict[str, Any]], + import_failures_unguarded: list[dict[str, Any]], + import_failures_guarded: list[dict[str, Any]], + constructor_failures: list[dict[str, Any]], + closure_not_on_colab: list[str] | None = None, + imported_modules: Iterable[str] = (), + accepted_missing: Iterable[dict[str, str]] = (), +) -> dict[str, Any]: + """Apply the gate's rules and return ``{ok, fails, warns, accepted}``. + + FAIL is reserved for the three shapes that break a real notebook: an + unguarded import of a module Colab will not have, a headline search that + cannot be constructed, and a declared dependency that is both missing and + actually imported. Everything else is reported and non-blocking — a + ``version_conflict`` is usually Colab pinning what Colab pins, a guarded + import already has a fallback, and a never-imported missing declaration + costs a notebook nothing. + """ + accepted_index = { + normalise(entry["name"]): entry.get("reason", "") + for entry in accepted_missing + if entry.get("name") + } + imported = {m for m in imported_modules} + imported_norm = {normalise(m) for m in imported} + + fails: list[str] = [] + warns: list[str] = [] + accepted: list[dict[str, str]] = [] + + def is_accepted(name: str) -> str | None: + key = normalise(name) + if key in accepted_index: + return accepted_index[key] + return None + + for entry in import_failures_unguarded: + module = entry["module"] + reason = is_accepted(module) + site = entry.get("sites", [{}])[0] + where = f"{site.get('file', '?')}:{site.get('line', '?')}" + if reason is not None: + accepted.append({"name": module, "kind": "unguarded import", "reason": reason}) + warns.append(f"accepted unguarded import {module} ({where}): {reason}") + else: + fails.append(f"{module} ({where})") + + for entry in constructor_failures: + name = entry["name"] + reason = is_accepted(name) + if reason is not None: + accepted.append({"name": name, "kind": "constructor", "reason": reason}) + warns.append(f"accepted constructor failure af.{name}: {reason}") + else: + fails.append(f"af.{name}() {entry.get('error', '')}".strip()) + + for entry in missing_declared: + name = entry["name"] + candidates = dist_module_candidates(name) + is_imported = bool( + candidates & imported + or {normalise(c) for c in candidates} & imported_norm + ) + entry["imported"] = is_imported + if not is_imported: + warns.append( + f"declared dependency {name} absent on Colab but never imported " + f"(required by {', '.join(entry.get('required_by', [])) or '?'})" + ) + continue + reason = is_accepted(name) + if reason is not None: + accepted.append({"name": name, "kind": "missing dependency", "reason": reason}) + warns.append(f"accepted missing dependency {name}: {reason}") + else: + fails.append( + f"{name} (declared by {', '.join(entry.get('required_by', [])) or '?'}, " + "absent on Colab, imported)" + ) + + for entry in version_conflict: + warns.append( + f"{entry['name']} {entry.get('installed')} outside " + f"{entry.get('specifier')} required by " + f"{', '.join(entry.get('required_by', [])) or '?'}" + ) + + for entry in import_failures_guarded: + warns.append( + f"guarded import {entry['module']} unavailable " + f"({len(entry.get('sites', []))} site(s))" + ) + + for name in closure_not_on_colab or []: + warns.append(f"declared in the with-deps closure but not shipped by Colab: {name}") + + return {"ok": not fails, "fails": fails, "warns": warns, "accepted": accepted} + + +def format_detail(fails: list[str], limit: int = 3) -> str: + """The RESULTS detail string for a failing gate.""" + head = "; ".join(fails[:limit]) + if len(fails) > limit: + head += f"; +{len(fails) - limit} more" + return f"colab gate: {head}" + + +# -------------------------------------------------------------------------- +# report writing +# -------------------------------------------------------------------------- + + +def _write_report(path: str | None, payload: dict[str, Any]) -> None: + if not path: + return + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_suffix(target.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + tmp.replace(target) + + +def _now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +# -------------------------------------------------------------------------- +# seed +# -------------------------------------------------------------------------- + + +def cmd_seed(ns: argparse.Namespace) -> int: + index_args = list(ns.index_args or []) + manifest, source, date, notes = load_manifest( + Path(ns.manifest_cache) if ns.manifest_cache else None, + Path(ns.snapshot) if ns.snapshot else None, + ) + print(f"colab_gate seed: manifest {source} ({date or 'date unknown'}), " + f"{len(manifest)} pinned packages") + for note in notes: + print(f" note: {note}") + + targets = list(ns.targets) + (["jax"] if ns.include_jax else []) + report: dict[str, Any] = { + "phase": "seed", + "ts": _now(), + "manifest_source": source, + "manifest_date": date, + "manifest_url": MANIFEST_URL, + "manifest_packages": len(manifest), + "manifest_notes": notes, + "targets": targets, + "python": sys.version.split()[0], + } + + try: + closure = resolve_closure(targets, index_args) + except Exception as exc: + report["ok"] = False + report["error"] = str(exc) + report["detail"] = f"colab gate: closure resolution failed ({exc.__class__.__name__})" + _write_report(ns.report_json, report) + print(f"colab_gate seed: FAILED — {exc}", file=sys.stderr) + return 1 + + pyauto = {normalise(p) for p in PYAUTO_PACKAGES} + wanted = colab_intersection(closure, manifest) + not_on_colab = sorted( + name for name in closure if name not in manifest and name not in pyauto + ) + print(f"colab_gate seed: closure {len(closure)} packages; " + f"{len(wanted)} also shipped by Colab; " + f"{len(not_on_colab)} not on Colab (deliberately NOT installed)") + + pins = [f"{name}=={manifest[name]}" for name in wanted] + seeded: list[dict[str, str]] = [] + failures: list[dict[str, str]] = [] + if pins: + result = _pip(["install", "--no-deps", "--quiet", *index_args, *pins]) + if result.returncode != 0: + # One unavailable pin fails the whole batch; retry singly so the + # report names the packages Colab pins to something this + # interpreter cannot install rather than losing all 50. + print("colab_gate seed: batch install failed, retrying per package") + for pin in pins: + single = _pip(["install", "--no-deps", "--quiet", *index_args, pin]) + name, _, version = pin.partition("==") + if single.returncode == 0: + seeded.append({"name": name, "version": version}) + else: + failures.append({ + "name": name, + "version": version, + "error": (single.stderr or single.stdout)[-300:], + }) + else: + seeded = [ + {"name": p.split("==")[0], "version": p.split("==")[1]} for p in pins + ] + + # --- complete the seeded set to a self-consistent Colab subset ---------- + # + # The intersection is installed --no-deps, so a seeded package's OWN runtime + # dependencies only land if they happen to be in the PyAuto closure too. + # They usually are not: Colab ships IPython 7.34.0, which needs pickleshare, + # and nothing in the autolens closure requires pickleshare — so `import jax` + # (which reaches IPython through its Colab debugger) died on a package Colab + # has had all along. A real Colab session is internally consistent, so the + # simulation must be too: walk the seeded packages' requirements and install + # anything COLAB ALSO SHIPS that is still missing, at Colab's pin. Nothing + # absent from the manifest is ever installed here — that absence is the + # whole measurement. + def _install(pins: list[str]) -> tuple[bool, str]: + result = _pip(["install", "--no-deps", "--quiet", *index_args, *pins]) + return result.returncode == 0, (result.stderr or result.stdout) + + completion = walk_requirements( + [entry["name"] for entry in seeded], manifest, install=_install + ) + for entry in completion["colab_installed"]: + seeded.append({"name": entry["name"], "version": entry["version"]}) + if completion["colab_installed"]: + print(f"colab_gate seed: completed the Colab subset with " + f"{len(completion['colab_installed'])} further Colab-shipped " + f"dependencies of the seeded packages") + + report.update({ + "ok": True, + "closure": closure, + "seeded": seeded, + "seed_failures": failures, + "seed_completion": { + "rounds": completion["rounds"], + "installed": completion["colab_installed"], + # Dependencies of Colab's own packages that Colab does not ship. + # Informational: Colab lives with them, so the gate does too. + "not_on_colab": completion["missing_declared"], + }, + "closure_not_on_colab": [ + {"name": name, "closure_version": closure[name]} for name in not_on_colab + ], + }) + _write_report(ns.report_json, report) + print(f"colab_gate seed: installed {len(seeded)} Colab-pinned packages" + + (f", {len(failures)} could not be installed" if failures else "")) + for entry in failures: + print(f" seed failure: {entry['name']}=={entry['version']}") + return 0 + + +# -------------------------------------------------------------------------- +# verify +# -------------------------------------------------------------------------- + + +def _package_roots() -> dict[str, Path]: + import importlib.util + + roots: dict[str, Path] = {} + for package in PYAUTO_PACKAGES: + try: + spec = importlib.util.find_spec(package) + except Exception: + continue + if spec is None or not spec.submodule_search_locations: + continue + roots[package] = Path(list(spec.submodule_search_locations)[0]) + return roots + + +def _installed_non_pyauto() -> dict[str, str]: + from importlib import metadata + + pyauto = {normalise(p) for p in PYAUTO_PACKAGES} + out: dict[str, str] = {} + for dist in metadata.distributions(): + name = dist.metadata["Name"] + if not name: + continue + key = normalise(name) + if key in pyauto: + continue + out[key] = dist.version + return out + + +def cmd_verify(ns: argparse.Namespace) -> int: + index_args = list(ns.index_args or []) + manifest, source, date, notes = load_manifest( + Path(ns.manifest_cache) if ns.manifest_cache else None, + Path(ns.snapshot) if ns.snapshot else None, + ) + config = load_config(Path(ns.config) if ns.config else None) + + closure_not_on_colab: list[str] = [] + seed_report_path = getattr(ns, "seed_report", None) + if seed_report_path and Path(seed_report_path).is_file(): + try: + seed_data = json.loads(Path(seed_report_path).read_text()) + except (OSError, ValueError): + seed_data = {} + closure_not_on_colab = [ + entry["name"] + for entry in seed_data.get("closure_not_on_colab", []) + if isinstance(entry, dict) and entry.get("name") + ] + + report: dict[str, Any] = { + "phase": "verify", + "ts": _now(), + "manifest_source": source, + "manifest_date": date, + "manifest_packages": len(manifest), + "manifest_notes": notes, + "python": sys.version.split()[0], + } + + installed_pyauto = { + package: _installed_version(package) for package in PYAUTO_PACKAGES + } + report["packages"] = installed_pyauto + print("colab_gate verify: installed PyAuto stack — " + ", ".join( + f"{k}={v}" for k, v in installed_pyauto.items() + )) + + def install(pins: list[str]) -> tuple[bool, str]: + result = _pip(["install", "--no-deps", "--quiet", *index_args, *pins]) + return result.returncode == 0, (result.stderr or result.stdout) + + # --- 1. declared-requirement walk --------------------------------------- + walk = walk_requirements( + [p for p in PYAUTO_PACKAGES if installed_pyauto.get(p)], + manifest, + install=install, + ) + report["walk"] = { + "rounds": walk["rounds"], + "colab_installed": walk["colab_installed"], + "unparsed_requirements": walk["unparsed_requirements"], + } + report["missing_declared"] = walk["missing_declared"] + report["version_conflict"] = walk["version_conflict"] + print(f"colab_gate verify: requirement walk visited {len(walk['walked'])} " + f"distributions in {walk['rounds']} round(s); " + f"{len(walk['colab_installed'])} healed from Colab's pins, " + f"{len(walk['missing_declared'])} declared-but-absent, " + f"{len(walk['version_conflict'])} version conflict(s)") + + # --- 2. import probe ----------------------------------------------------- + roots = _package_roots() + sites = scan_package_imports(roots) + probe = probe_imports(sorted(sites)) + + # An import can fail here for a module Colab ships perfectly well: the seed + # installs the closure of the PyAuto stack, and a library may import + # something Colab has but nothing in that closure declares (numba is the + # live example). On Colab that import succeeds, so reporting it as a miss + # would be the gate lying about the platform. Install Colab's pin and probe + # again — the same rule the requirement walk applies, reached by a + # different route. + healable: dict[str, str] = {} + for module, error in probe.items(): + if error is None: + continue + for candidate in module_dist_candidates(module): + if candidate in manifest: + healable[module] = f"{candidate}=={manifest[candidate]}" + break + colab_healed: list[dict[str, str]] = [] + if healable: + print(f"colab_gate verify: {len(healable)} failed import(s) are shipped by " + f"Colab — installing Colab's pins and re-probing") + pins = sorted(set(healable.values())) + ok, output = install(pins) + # --no-deps again, so the package's own Colab-shipped dependencies have + # to be completed the same way the seed completes its set — `numba` + # without `llvmlite` imports no better than no numba at all. + walk_requirements( + [pin.split("==", 1)[0] for pin in pins], manifest, install=install + ) + reprobe = probe_imports(sorted(healable)) + for module, pin in sorted(healable.items()): + probe[module] = reprobe.get(module, probe[module]) + if probe[module] is None: + colab_healed.append({"module": module, "pin": pin}) + if not ok: + report["manifest_notes"] = list(notes) + [ + f"installing Colab pins for failed imports reported: {output[-200:]}" + ] + report["imports_provided_by_colab"] = colab_healed + + unguarded: list[dict[str, Any]] = [] + guarded: list[dict[str, Any]] = [] + for module in sorted(sites): + error = probe.get(module) + if error is None: + continue + entry = { + "module": module, + "error": error, + "sites": sites[module][:10], + "n_sites": len(sites[module]), + } + if any(not site["guarded"] for site in sites[module]): + entry["sites"] = [s for s in sites[module] if not s["guarded"]][:10] + unguarded.append(entry) + else: + guarded.append(entry) + report["imports_probed"] = len(sites) + report["import_failures_unguarded"] = unguarded + report["import_failures_guarded"] = guarded + print(f"colab_gate verify: probed {len(sites)} third-party imports — " + f"{len(unguarded)} unguarded failure(s), {len(guarded)} guarded") + + # --- 3. search constructors --------------------------------------------- + constructors = probe_constructors() + constructor_failures = [ + {"name": name, "error": error} + for name, error in sorted(constructors.items()) + if error is not None + ] + report["constructor_failures"] = constructor_failures + print(f"colab_gate verify: constructed {len(constructors) - len(constructor_failures)}" + f"/{len(constructors)} searches") + + # --- 4. extras the bootstrap added on top of Colab ------------------------ + installed = _installed_non_pyauto() + extras = sorted(name for name in installed if name not in manifest) + report["extras_not_on_colab"] = [ + {"name": name, "version": installed[name]} for name in extras + ] + colab_provided = sorted(name for name in installed if name in manifest) + report["colab_provided"] = len(colab_provided) + + # --- 5. verdict ----------------------------------------------------------- + result = verdict( + missing_declared=report["missing_declared"], + version_conflict=report["version_conflict"], + import_failures_unguarded=unguarded, + import_failures_guarded=guarded, + constructor_failures=constructor_failures, + closure_not_on_colab=closure_not_on_colab, + imported_modules=sites.keys(), + accepted_missing=config["accepted_missing"], + ) + report.update(result) + + if result["ok"]: + report["detail"] = ( + f"Colab manifest {source} {date or 'date unknown'}; " + f"{len(colab_provided)} Colab-provided, {len(extras)} extras, " + f"{len(sites)} imports probed" + ) + else: + report["detail"] = format_detail(result["fails"]) + + _write_report(ns.report_json, report) + + print() + print("colab_gate verify: " + ("PASS" if result["ok"] else "FAIL")) + for line in result["fails"]: + print(f" FAIL {line}") + for line in result["warns"]: + print(f" WARN {line}") + print(f" {report['detail']}") + return 0 if result["ok"] else 1 + + +# -------------------------------------------------------------------------- +# cli +# -------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="colab_gate", + description="Colab package-set gate for verify_install check F.", + ) + sub = parser.add_subparsers(dest="command", required=True) + + def common(p: argparse.ArgumentParser) -> None: + p.add_argument("--report-json", help="write the machine-readable report here") + p.add_argument( + "--manifest-cache", + help="path the live Colab manifest is cached to / read back from", + ) + p.add_argument( + "--snapshot", + help=f"vendored manifest fallback (default: {SNAPSHOT_PATH})", + ) + + seed = sub.add_parser("seed", help="install Colab's package set before the setup cell") + common(seed) + seed.add_argument( + "--targets", nargs="+", required=True, + help="the PyAuto install targets whose with-deps closure is resolved", + ) + seed.add_argument( + "--no-jax", dest="include_jax", action="store_false", + help="do not add jax to the closure targets (Colab ships jax)", + ) + seed.add_argument( + "--index-args", nargs=argparse.REMAINDER, default=[], + help="everything after this flag is passed straight to pip (must be last)", + ) + seed.set_defaults(func=cmd_seed, include_jax=True) + + verify = sub.add_parser("verify", help="audit the environment the setup cell left") + common(verify) + verify.add_argument("--config", help=f"accepted-miss config (default: {CONFIG_PATH})") + verify.add_argument( + "--seed-report", + help="the seed phase report, whose closure_not_on_colab list is folded in as WARNs", + ) + verify.add_argument( + "--index-args", nargs=argparse.REMAINDER, default=[], + help="everything after this flag is passed straight to pip (must be last)", + ) + verify.set_defaults(func=cmd_verify) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + ns = build_parser().parse_args(list(argv) if argv is not None else sys.argv[1:]) + try: + return ns.func(ns) + except Exception as exc: # tool error, distinct from a gate failure + print(f"colab_gate: {exc.__class__.__name__}: {exc}", file=sys.stderr) + import traceback + + traceback.print_exc() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/heart/checks/colab_pip_freeze.snapshot.txt b/heart/checks/colab_pip_freeze.snapshot.txt new file mode 100644 index 0000000..152a24e --- /dev/null +++ b/heart/checks/colab_pip_freeze.snapshot.txt @@ -0,0 +1,649 @@ +# source: googlecolab/backend-info pip-freeze.txt fetched 2026-09-15 +# Do not modify this file directly; it is generated by extract_colabx_testing_tarballs.sh via +# $ python3 -m pip freeze +# Be aware that this list does not necessarily reflect the current state of the +# staging or production container, but rather the state as of the most recent +# submitted CL where extract_colabx_testing_tarballs.sh was run. +absl-py==1.4.0 +accelerate==1.14.0 +access==1.1.10.post3 +affine==3.0.1 +aiofiles==25.1.0 +aiohappyeyeballs==2.7.1 +aiohttp==3.14.3 +aiosignal==1.4.0 +aiosqlite==0.22.1 +alabaster==1.0.0 +albucore==0.0.24 +albumentations==2.0.8 +ale-py==0.12.1 +altair==5.5.0 +annotated-doc==0.0.5 +annotated-types==0.8.0 +antlr4-python3-runtime==4.9.3 +anyio==4.14.2 +anywidget==0.9.21 +apsw==3.53.4.0 +argon2-cffi==25.1.0 +argon2-cffi-bindings==26.1.0 +array_record==0.8.3 +arrow==1.4.0 +arviz==0.22.0 +astropy==7.2.2 +astropy-iers-data==0.2026.8.31.0.57.9 +astunparse==1.6.3 +atpublic==5.1 +attrs==26.1.0 +audioop-lts==0.2.2 +audioread==3.1.0 +Authlib==1.8.0 +autograd==1.9.1 +babel==2.18.0 +backcall==0.2.0 +beartype==0.22.9 +beautifulsoup4==4.13.5 +betterproto==2.0.0b7 +bigframes==2.48.0 +bigquery-magics==0.14.0 +bleach==6.4.0 +blinker==1.9.0 +blis==1.3.3 +blobfile==3.3.0 +blosc2==4.12.0 +bokeh==3.8.2 +Bottleneck==1.4.2 +bqplot==0.12.47 +branca==0.8.2 +brotli==1.2.0 +CacheControl==0.14.4 +cachetools==6.2.6 +catalogue==2.0.10 +certifi==2026.7.22 +cffi==2.1.1 +chardet==5.2.0 +charset-normalizer==3.4.9 +clarabel==0.11.1 +click==8.5.0 +cligj==0.7.2 +cloudpathlib==0.25.0 +cloudpickle==3.1.2 +cmake==3.31.10 +cmdstanpy==1.3.0 +colorcet==3.2.1 +colorlover==0.3.0 +community==1.0.0b1 +confection==1.3.3 +cons==0.4.7 +contourpy==1.3.3 +cramjam==2.12.1 +cryptography==50.0.1 +cufflinks==0.17.3 +curl_cffi==0.16.2 +cvxopt==1.3.2 +cvxpy==1.6.7 +cycler==0.12.1 +cyipopt==1.5.0 +cymem==2.0.13 +Cython==3.0.12 +dask==2026.8.0 +dataproc-spark-connect==1.1.0 +datasets==4.8.5 +db-dtypes==1.7.1 +dbus-python==1.3.2 +debugpy==1.8.15 +decorator==4.4.2 +defusedxml==0.7.1 +deprecation==2.1.0 +diffusers==0.40.0 +dill==0.4.1 +distro==1.9.0 +dlib==19.24.6 +dm-tree==0.1.10 +docstring_parser==0.18.0 +docutils==0.21.2 +dopamine_rl==4.1.2 +duckdb==1.3.2 +earthengine-api==1.7.39 +easydict==1.13 +editdistance==0.8.1 +eerepr==0.1.2 +einops==0.8.2 +en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl#sha256=1932429db727d4bff3deed6b34cfc05df17794f4a52eeb26cf8928f7c1a0fb85 +entrypoints==0.4 +esda==2.9.0 +et_xmlfile==2.0.0 +etils==1.14.0 +etuples==0.3.10 +Farama-Notifications==0.0.6 +fastai==2.8.8 +fastapi==0.141.1 +fastcore==2.2.19 +fastdownload==0.0.7 +fastjsonschema==2.22.2 +fastprogress==1.1.6 +fasttransform==0.0.2 +filelock==3.32.5 +firebase-admin==6.9.0 +Flask==3.1.3 +flatbuffers==25.12.19 +flax==0.11.2 +folium==0.20.0 +fonttools==4.64.0 +formulaic==1.2.2 +fqdn==1.5.1 +frozendict==2.4.7 +frozenlist==1.8.0 +fsspec==2025.12.0 +future==1.0.0 +gast==0.7.0 +gcsfs==2025.12.0 +GDAL==3.8.4 +gdown==5.2.2 +geemap==0.38.4 +geocoder==1.38.1 +geographiclib==2.1 +geopandas==1.1.4 +geopy==2.4.1 +giddy==2.3.9 +gin-config==0.5.0 +glob2==0.7 +google==3.0.0 +google-adk==2.7.1 +google-ai-generativelanguage==0.6.15 +google-api-core==2.30.3 +google-api-python-client==2.200.0 +google-auth==2.49.0 +google-auth-httplib2==0.4.2 +google-auth-oauthlib==1.4.1 +google-cloud-bigquery==3.44.0 +google-cloud-bigquery-connection==1.22.0 +google-cloud-bigquery-storage==2.39.0 +google-cloud-core==2.7.0 +google-cloud-dataproc==5.28.0 +google-cloud-datastore==2.25.0 +google-cloud-firestore==2.27.0 +google-cloud-functions==1.24.0 +google-cloud-language==2.21.0 +google-cloud-monitoring==2.31.0 +google-cloud-resource-manager==1.18.0 +google-cloud-spanner==3.68.0 +google-cloud-storage==3.13.1 +google-cloud-storage-control==1.12.0 +google-cloud-translate==3.27.0 +google-colab @ file:///colabtools/dist/google_colab-1.0.0.tar.gz +google-crc32c==1.8.0 +google-genai==2.12.1 +google-generativeai==0.8.6 +google-pasta==0.2.0 +google-resumable-media==2.10.2 +googleapis-common-protos==1.75.0 +googledrivedownloader==1.1.0 +gradio==6.26.0 +gradio_client==2.6.1 +grain==0.2.18 +graphviz==0.21 +greenlet==3.5.5 +groovy==0.1.2 +grpc-google-iam-v1==0.14.4 +grpc-interceptor==0.15.4 +grpcio==1.83.1 +grpcio-status==1.71.2 +grpclib==0.4.9 +gspread==6.2.1 +gspread-dataframe==4.0.0 +gym==0.25.2 +gym-notices==0.1.0 +gymnasium==1.3.0 +h11==0.16.0 +h2==4.4.1 +h5netcdf==1.8.1 +h5py==3.16.0 +hdbscan==0.8.44 +hf-gradio==0.4.1 +hf-xet==1.6.0 +highspy==1.15.1 +holidays==0.103 +holoviews==1.23.2 +hpack==4.2.0 +html5lib==1.1 +httpcore==1.0.9 +httpcore2==2.12.0 +httpimport==1.4.1 +httplib2==0.32.0 +httptools==0.8.0 +httpx==0.28.1 +httpx2==2.12.0 +huggingface_hub==1.29.0 +humanize==4.16.0 +hyperframe==6.1.0 +hyperopt==0.3.0 +ibis-framework==9.5.0 +idna==3.19 +ImageIO==2.37.4 +imageio-ffmpeg==0.6.0 +imagesize==2.0.1 +imbalanced-learn==0.14.2 +immutabledict==4.3.1 +importlib_metadata==9.0.1 +imutils==0.5.4 +inequality==1.1.2 +inflect==7.5.0 +iniconfig==2.3.0 +intel-cmplr-lib-ur==2025.3.3 +intel-openmp==2025.3.3 +interface_meta==2.0.1 +ipyevents==2.0.4 +ipyfilechooser==0.6.0 +ipykernel==6.17.1 +ipyleaflet==0.20.0 +ipyparallel==8.8.0 +ipython==7.34.0 +ipython-genutils==0.2.0 +ipython-sql==0.5.0 +ipywidgets==7.7.1 +isoduration==20.11.0 +itsdangerous==2.2.0 +jaraco.classes==3.4.0 +jaraco.context==6.1.2 +jaraco.functools==4.6.0 +jax==0.11.1 +jaxlib==0.11.1 +jeepney==0.9.0 +jieba==0.42.1 +Jinja2==3.1.6 +jiter==0.16.0 +joblib==1.6.0 +joserfc==1.7.5 +jsonpatch==1.33 +jsonpickle==4.1.2 +jsonpointer==3.1.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +jupyter-console==6.6.3 +jupyter-events==0.12.1 +jupyter-leaflet==0.20.0 +jupyter_client==7.4.9 +jupyter_core==5.9.1 +jupyter_kernel_gateway @ git+https://github.com/googlecolab/kernel_gateway@b134e9945df25c2dcb98ade9129399be10788671 +jupyter_server==2.20.0 +jupyter_server_terminals==0.5.4 +jupyterlab_pygments==0.3.0 +jupyterlab_widgets==3.0.17 +jupytext==1.19.5 +kaggle==2.0.2 +kagglehub==1.0.2 +kagglesdk==0.1.37 +keras==3.13.2 +keras-hub==0.26.0 +keras-nlp==0.26.0 +keyring==25.7.0 +keyrings.google-artifactregistry-auth==1.1.2 +kiwisolver==1.5.1 +langchain==1.3.18 +langchain-core==1.6.1 +langchain-protocol==0.0.19 +langgraph==1.2.11 +langgraph-checkpoint==4.2.0 +langgraph-prebuilt==1.1.0 +langgraph-sdk==0.4.4 +langsmith==0.12.1 +lark==1.3.1 +launchpadlib==1.11.0 +lazr.restfulclient==0.14.6 +lazr.uri==1.0.6 +lazy-loader==0.5 +libclang==18.1.1 +libpysal==4.14.1 +librosa==0.11.0 +lightgbm==4.6.0 +linkify-it-py==2.2.0 +llvmlite==0.44.0 +locket==1.0.0 +logical-unification==0.4.7 +lxml==6.1.2 +Mako==1.3.2.dev0 +mapclassify==2.10.0 +Markdown==3.10.3 +markdown-it-py==4.2.0 +MarkupSafe==3.0.3 +matplotlib==3.10.0 +matplotlib-inline==0.2.2 +matplotlib-venn==1.1.2 +mdit-py-plugins==0.6.1 +mdurl==0.1.2 +mgwr==2.2.1 +miniKanren==1.0.5 +missingno==0.5.2 +mistune==3.3.4 +mizani==0.13.5 +mkl==2025.3.1 +ml_dtypes==0.6.0 +mlxtend==0.23.4 +mmh3==5.3.0 +momepy==0.11.0 +more-itertools==10.8.0 +moviepy==1.0.3 +mpmath==1.3.0 +msgpack==1.2.2 +multidict==6.7.1 +multipledispatch==1.0.0 +multiprocess==0.70.19 +multitasking==0.0.13 +murmurhash==1.0.15 +music21==9.9.2 +namex==0.1.0 +narwhals==2.25.0 +natsort==8.4.0 +nbclassic==1.3.3 +nbclient==0.10.4 +nbconvert==7.17.1 +nbformat==5.11.1 +ndindex==1.10.1 +nest-asyncio==1.6.0 +networkx==3.6.1 +nh3==0.3.7 +nibabel==5.4.2 +nltk==3.9.1 +notebook==6.5.7 +notebook_shim==0.2.4 +numba==0.61.2 +numexpr==2.14.2 +numpy==2.1.3 +nvidia-nccl-cu13==2.31.2 +oauth2client==4.1.3 +oauthlib==3.3.1 +omegaconf==2.3.1 +onemkl-license==2025.3.1 +openai==2.54.0 +opencv-contrib-python==4.14.0.94 +opencv-python==5.0.0.93 +opencv-python-headless==5.0.0.93 +openpyxl==3.1.5 +opentelemetry-api==1.42.1 +opentelemetry-resourcedetector-gcp==1.14.0 +opentelemetry-sdk==1.42.1 +opentelemetry-semantic-conventions==0.63b1 +opt_einsum==3.4.0 +optax==0.2.8 +optree==0.20.0 +orbax-checkpoint==0.12.4 +orjson==3.12.0 +ormsgpack==1.12.2 +osqp==1.1.3 +packaging==26.3 +pandas==2.2.3 +pandas-datareader==0.11.1 +pandas-gbq==0.30.0 +pandas-stubs==2.2.3.250527 +pandocfilters==1.5.1 +panel==1.9.4 +panel-material-ui==0.14.2 +param==2.4.1 +parso==0.8.7 +parsy==2.2 +partd==1.4.2 +patsy==1.0.3 +peewee==4.4.0 +peft==0.20.0 +pexpect==4.9.0 +pickleshare==0.7.5 +pillow==11.3.0 +pip==24.1.2 +platformdirs==4.11.7 +plotly==5.24.1 +plotnine==0.14.5 +pluggy==1.6.0 +plum-dispatch==2.9.0 +pointpats==2.5.5 +polars==1.35.2 +polars-runtime-32==1.35.2 +pooch==1.9.0 +portpicker==1.5.2 +preshed==3.0.13 +prettytable==3.18.0 +proglog==0.1.12 +progressbar2==4.5.0 +prometheus_client==0.26.0 +promise==2.3 +prompt_toolkit==3.0.53 +propcache==0.5.2 +prophet==1.4.0 +proto-plus==1.28.2 +protobuf==5.29.6 +psutil==5.9.5 +psycopg2==2.9.12 +psygnal==0.15.1 +ptyprocess==0.7.0 +PuLP==3.3.2 +py-cpuinfo==9.0.0 +py4j==0.10.9.9 +pyarrow==23.0.1 +pyasn1==0.6.4 +pyasn1_modules==0.4.2 +pycairo==1.29.1 +pycocotools==2.0.11 +pycparser==3.0 +pycryptodomex==3.23.0 +pydantic==2.13.5 +pydantic_core==2.46.5 +pydata-google-auth==1.9.1 +pydot==4.0.1 +pydotplus==2.0.2 +PyDrive2==1.21.1 +pydub==0.25.1 +pyerfa==2.0.1.5 +pygame==2.6.1 +pygit2==1.20.0 +Pygments==2.21.0 +PyGObject==3.48.2 +pyiceberg==0.12.0 +PyJWT==2.13.0 +pymc==5.28.5 +pynndescent==0.6.0 +pyogrio==0.13.0 +pyomo==6.10.1 +PyOpenGL==3.1.10 +pyOpenSSL==26.4.0 +pyparsing==3.3.2 +pyperclip==1.11.0 +pyproj==3.7.2 +pyroaring==1.1.0 +pysal==25.7 +pyshp==3.1.6 +PySocks==1.7.1 +pyspark==4.0.4 +pytensor==2.38.3 +pytest==8.4.2 +python-apt==0.0.0 +python-box==7.4.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.3 +python-fasthtml==0.14.12 +python-json-logger==4.2.0 +python-louvain==0.16 +python-multipart==0.0.32 +python-slugify==8.0.4 +python-snappy==0.7.3 +python-utils==4.0.1 +pytz==2025.2 +pyviz_comms==3.0.6 +PyWavelets==1.9.0 +PyYAML==6.0.3 +pyzmq==26.2.1 +quantecon==0.11.4 +rasterio==1.5.1 +rasterstats==0.21.0 +ratelim==0.1.6 +referencing==0.37.0 +regex==2025.11.3 +requests==2.32.4 +requests-oauthlib==2.0.0 +requests-toolbelt==1.0.0 +requirements-parser==0.9.0 +rfc3339-validator==0.1.4 +rfc3986-validator==0.1.1 +rfc3987-syntax==1.1.0 +rich==13.9.4 +roman-numerals==4.1.0 +roman-numerals-py==4.1.0 +rpds-py==2026.6.3 +rpy2==3.5.17 +rsa==4.9.1 +rtree==1.4.1 +safehttpx==0.1.7 +safetensors==0.8.0 +scikit-image==0.25.2 +scikit-learn==1.6.1 +scipy==1.16.3 +scooby==0.11.2 +scs==3.2.11 +seaborn==0.13.2 +SecretStorage==3.5.0 +segregation==2.5.4 +semantic-version==2.10.0 +Send2Trash==2.1.0 +sentence-transformers==5.7.0 +sentencepiece==0.2.2 +sentry-sdk==2.68.1 +setuptools==80.10.2 +shap==0.52.0 +shapely==2.1.2 +shellingham==1.5.4 +simple-parsing==0.1.9 +simplejson==4.1.2 +simsimd==6.5.16 +six==1.17.0 +sklearn-compat==0.1.6 +sklearn-pandas==2.2.0 +slicer==0.0.8 +smart_open==8.0.1 +sniffio==1.3.1 +snowballstemmer==3.1.1 +soundfile==0.14.0 +soupsieve==2.9.2 +soxr==1.1.0 +spacy==3.8.16 +spacy-legacy==3.0.12 +spacy-loggers==1.0.5 +spaghetti==1.7.6 +spanner-graph-notebook==1.1.10 +spglm==1.1.0 +Sphinx==8.2.3 +sphinxcontrib-applehelp==2.0.0 +sphinxcontrib-devhelp==2.0.0 +sphinxcontrib-htmlhelp==2.1.0 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==2.0.0 +sphinxcontrib-serializinghtml==2.0.0 +spint==1.1.0 +splot==1.1.7 +spopt==0.7.0 +spreg==1.9.0 +SQLAlchemy==2.0.52 +sqlglot==25.20.2 +sqlparse==0.6.0 +srsly==2.5.3 +standard-aifc==3.13.0 +standard-chunk==3.13.0 +standard-sunau==3.13.0 +stanio==0.5.1 +starlette==1.6.0 +statsmodels==0.15.0 +strictyaml==1.7.3 +stringzilla==5.1.2 +stumpy==1.14.1 +sympy==1.14.0 +tables==3.10.2 +tabulate==0.9.0 +tbb==2022.3.1 +tcmlib==1.5.0 +tenacity==9.1.4 +tensorboard==2.20.0 +tensorboard-data-server==0.7.2 +tensorflow==2.20.0 +tensorflow-datasets==4.9.10 +tensorflow-hub==0.16.1 +tensorflow-metadata==1.21.0 +tensorflow-probability==0.25.0 +tensorflow-text==2.20.1 +tensorstore==0.1.85 +termcolor==3.3.0 +terminado==0.18.1 +text-unidecode==1.3 +textblob==0.19.0 +tf-slim==1.1.0 +tf_keras==2.20.1 +thinc==8.3.13 +threadpoolctl==3.6.0 +tifffile==2026.8.23 +tiktoken==0.14.0 +timm==1.0.29 +tinycss2==1.5.1 +tobler==0.14.0 +tokenizers==0.23.1 +toml==0.10.2 +tomlkit==0.14.0 +toolz==0.12.1 +torch @ https://download.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl +torchao==0.10.0 +torchaudio @ https://download.pytorch.org/whl/cpu/torchaudio-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl +torchcodec @ https://download.pytorch.org/whl/cpu/torchcodec-0.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl +torchdata==0.11.0 +torchsummary==1.5.1 +torchtune==0.6.1 +torchvision @ https://download.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl +tornado==6.5.7 +tqdm==4.67.3 +traitlets==5.7.1 +traittypes==0.2.3 +transformers==5.16.1 +treescope==0.1.10 +truststore==0.10.4 +tsfresh==0.21.2 +tweepy==4.17.0 +typeguard==4.6.0 +typer==0.27.2 +types-pytz==2026.3.1.20260727 +types-setuptools==84.0.0.20260812 +typing-inspection==0.4.4 +typing_extensions==4.16.0 +tzdata==2026.3 +tzlocal==5.4.4 +umap-learn==0.5.12 +umf==1.0.3 +uri-template==1.3.0 +uritemplate==4.2.0 +urllib3==2.5.0 +uuid_utils==0.17.0 +uvicorn==0.52.4 +uvloop==0.22.1 +vega-datasets==0.9.0 +wadllib==1.3.6 +wandb==0.28.1 +wasabi==1.1.3 +watchdog==6.0.0 +watchfiles==1.2.0 +wcwidth==0.8.3 +weasel==1.0.0 +webcolors==25.10.0 +webencodings==0.6.1 +websocket-client==1.9.2 +websockets==15.0.1 +Werkzeug==3.1.8 +wheel==0.48.0 +widgetsnbextension==3.6.10 +wordcloud==1.9.6 +wrapt==2.4.0 +xarray==2025.12.0 +xarray-einstats==0.11.0 +xgboost==3.4.1 +xlrd==2.0.2 +xxhash==4.0.1 +xyzservices==2026.3.0 +yarl==1.24.5 +ydf==0.15.0 +ydf_tf==2.20.0 +yellowbrick==1.5 +yfinance==0.2.66 +zipp==4.1.0 +zstandard==0.25.0 diff --git a/heart/checks/verify_install.sh b/heart/checks/verify_install.sh index 013629a..85d5e11 100755 --- a/heart/checks/verify_install.sh +++ b/heart/checks/verify_install.sh @@ -16,7 +16,9 @@ # C conda install flow (python=3.12) + start_here.py + welcome.py # D pip install "autolens[optional]" resolves # E pip install autolens==2026.2.26.4 on Python 3.12 by explicit pin -# F Colab simulation: fake google.colab + the injected setup cell + run a cell +# F Colab gate: a venv holding Google's Colab package set, the injected +# setup cell verbatim (--no-deps bootstrap + workspace clone), an audit +# of every import/declared dependency the bootstrap left unmet, then a cell # # Each check creates its own throwaway venv / conda env and reports # PASS / FAIL / SKIP. A failed check never aborts the suite. Cleanup runs at @@ -65,8 +67,11 @@ Checks: C conda install flow (python=3.12) + start_here.py + welcome.py D pip install "autolens[optional]" resolves and imports E pip install autolens==2026.2.26.4 (yanked) installs on python3.12 by explicit pin - F Colab simulation: fake google.colab, run the injected setup cell - (pip bootstrap + setup_colab + workspace clone), then a notebook cell + F Colab gate: build a python3.12 venv holding the package set Google's + Colab ships (googlecolab/backend-info), run the injected setup cell + verbatim (--no-deps bootstrap + workspace clone), then audit every + third-party import and declared dependency the bootstrap left unmet + before running a real notebook cell Default: run all checks. @@ -84,6 +89,15 @@ Options: (consumed by pyauto-heart readiness; `index` is pypi, testpypi, or find-links). -h, --help Show this help. + +Environment: + COLAB_GATE_AUTONERVES_SRC + Check F only, dev/witness use. A path or requirement + installed --no-deps over the released `autonerves` right + after the setup cell's own bootstrap install, so an + UNRELEASED autonerves/setup_colab.py package list can be + rehearsed against the gate. Never set this in CI: a + release gate must read the wheels about to ship. USAGE } @@ -214,6 +228,14 @@ RESULTS_LOG="" # captured tail appended below the table on FAIL ARTEFACTS=() # paths to rm -rf at end CONDA_ENVS=() # conda env names to remove at end +# Check F caches Google's Colab pip-freeze manifest next to Heart's other state +# (same default as heart/state.py) so an offline/rate-limited run degrades to +# the last real manifest rather than to the snapshot vendored in this repo. +HEART_STATE_DIR="${HEART_STATE_DIR:-$HOME/.pyauto-heart}" +COLAB_MANIFEST_CACHE="$HEART_STATE_DIR/colab_pip_freeze.txt" +F_GATE_SEED_JSON="" # colab_gate seed report, folded into the sidecar +F_GATE_VERIFY_JSON="" # colab_gate verify report, folded into the sidecar + PIP_INSTALL_TARGET="autolens" PIP_INSTALL_OPTIONAL="autolens[optional]" if [ -n "$TARGET_VERSION" ]; then @@ -680,38 +702,76 @@ check_e() { fi } -# ----- check F: Colab simulation — the injected setup cell end-to-end ----- +# ----- check F: Colab simulation — the Colab package set, then the setup cell ----- +# +# This check used to "emulate Colab" with `pip install autolens jax` WITH +# dependencies. That install is what made the check blind: the venv already +# held corner, optax, xxhash, blackjax and every other declared dependency +# before the setup cell ran, so the cell's real `pip install ... --no-deps` +# could never be observed to miss one. Notebooks that die on Colab at the first +# post-fit plot passed check F for months. +# +# The venv is now built from the package set Google actually ships (the +# `googlecolab/backend-info` pip-freeze manifest) via colab_gate.py: +# +# seed resolve the with-deps closure WITHOUT installing it, install only +# the part of it Colab also ships, at Colab's pinned versions +# the injected setup cell verbatim: `pip install ... --no-deps` + +# workspace clone + autonerves config +# verify walk the installed libraries' declared requirements, AST-scan every +# import in their source and probe each one for real, construct the +# headline searches +# one real notebook cell (al.Imaging.from_fits) +# +# The interpreter is python3.12 because Colab is python3.12 — check B's +# "missing required interpreter is FAIL" rule applies here too. +# +# COLAB_GATE_AUTONERVES_SRC (dev/witness only): a path or requirement installed +# `--no-deps` immediately after the setup cell's verbatim `pip install +# autonerves --no-deps`. It exists to rehearse an UNRELEASED setup_colab.py — +# the bootstrap package list is the thing this check gates, and until it is on +# PyPI there is no other way to run the gate against a fix. Never set in CI. check_f() { echo - echo "=== Check F: Colab simulation (fake google.colab + setup cell + notebook cell) ===" + echo "=== Check F: Colab gate (Colab package set + setup cell + package audit + notebook cell) ===" local venv="/tmp/autolens_verify_F_$TS" local ws_dir="/tmp/colab_sim_workspace_F_$TS" - ARTEFACTS+=("$venv" "$ws_dir") + local seed_json="/tmp/F_gate_seed_$TS.json" + local verify_json="/tmp/F_gate_verify_$TS.json" + # The two gate reports are read by the sidecar writer, which runs before + # cleanup — so they can be swept with everything else (and kept by --keep). + ARTEFACTS+=("$venv" "$ws_dir" "$seed_json" "$verify_json") + F_GATE_SEED_JSON="$seed_json" + F_GATE_VERIFY_JSON="$verify_json" + + # Colab runs Python 3.12. A different interpreter would seed Colab's pins + # against the wrong wheels, so a missing python3.12 is FAIL, not SKIP — + # the same rule Checks B and E apply to their required interpreters. + if ! command -v python3.12 > /dev/null 2>&1; then + RESULTS+=("F|FAIL|python3.12 not found") + return + fi - step "creating venv with python3 at $venv" - if ! make_venv "$venv" python3; then - RESULTS+=("F|FAIL|could not create venv with python3") + step "creating venv with python3.12 at $venv" + if ! make_venv "$venv" python3.12; then + RESULTS+=("F|FAIL|could not create venv with python3.12") return fi # shellcheck source=/dev/null source "$venv/bin/activate" - pip install --upgrade pip > /dev/null 2>&1 + # `packaging` is the gate's only non-stdlib dependency (it evaluates the + # environment markers and version specifiers in the requirement walk). + pip install --upgrade pip packaging > /dev/null 2>&1 - # Emulate the Colab base environment: Colab preinstalls the scientific - # stack and JAX; the injected setup cell installs the PyAuto packages - # --no-deps on top of that. - # # When a version is pinned (a TestPyPI rehearsal), pin ALL five PyAuto - # packages to it — otherwise pip installs `autolens` at the pinned dev - # version but resolves autoarray/autonerves/autofit/autogalaxy to the latest + # packages to it — otherwise the closure resolves `autolens` at the pinned + # dev version but autoarray/autonerves/autofit/autogalaxy to the latest # *final* release on PyPI (dev versions are pre-releases pip won't pick for - # a floor-only dependency), leaving Check F exercising an incoherent - # dev/released mix instead of the same wheels as Checks A/C/D. The later - # verbatim `pip install autonerves --no-deps` inside the driver then finds the - # pinned dev autonerves already satisfied, so it can't pull a released one. + # a floor-only dependency), so the gate would audit an incoherent + # dev/released mix instead of the same wheels as Checks A/C/D. local f_targets=("$PIP_INSTALL_TARGET") if [ -n "$TARGET_VERSION" ]; then f_targets=( @@ -722,10 +782,18 @@ check_f() { "autogalaxy==$TARGET_VERSION" ) fi - step "pip install ${f_targets[*]} jax (emulating Colab's preinstalled env)" - if ! pip install "${PIP_INDEX_ARGS[@]}" "${f_targets[@]}" jax |& tee /tmp/F_pip.log; then - RESULTS+=("F|FAIL|pip install ${f_targets[*]} jax failed") - tail_log "Check F pip output" "$(cat /tmp/F_pip.log 2>/dev/null)" + + step "seeding the venv with Colab's package set (closure of ${f_targets[*]} + jax)" + local seed_rc=0 + "$venv/bin/python" "$VERIFY_INSTALL_DIR/colab_gate.py" seed \ + --manifest-cache "$COLAB_MANIFEST_CACHE" \ + --report-json "$seed_json" \ + --targets "${f_targets[@]}" \ + --index-args "${PIP_INDEX_ARGS[@]}" |& tee /tmp/F_seed.log + seed_rc=${PIPESTATUS[0]} + if [ "$seed_rc" -ne 0 ]; then + RESULTS+=("F|FAIL|colab gate: seeding Colab's package set failed (rc=$seed_rc)") + tail_log "Check F colab_gate seed output" "$(cat /tmp/F_seed.log 2>/dev/null)" deactivate return fi @@ -743,11 +811,10 @@ check_f() { # stub needs the submodule real Colab provides. touch "$site/google/colab/output.py" - # The driver is the injected notebook cell verbatim, plus assertions and - # one real cell from the imaging start_here. Exit 3 = SKIP (installed - # autonerves predates the setup_colab registry). - step "running the Colab bootstrap driver" - cat > /tmp/F_driver.py <<'PYEOF' + # --- driver part 1: the injected notebook cell, verbatim, then setup --- + # Exit 3 = SKIP (installed autonerves predates the setup_colab registry). + step "running the Colab bootstrap driver (setup cell + workspace clone)" + cat > /tmp/F_driver_setup.py <<'PYEOF' import os import subprocess import sys @@ -766,6 +833,22 @@ else: ) _setup_colab = importlib.import_module("autonerves.setup_colab") +# --- dev/witness override: rehearse an unreleased setup_colab.py --- +# +# The bootstrap package list this check gates lives in autonerves.setup_colab. +# A fix to it cannot be exercised until it is on PyPI unless the local source +# can be laid over the released wheel here, which is what this does. Not set in +# CI: a release gate must read the wheels that are about to ship. +_autonerves_src = os.environ.get("COLAB_GATE_AUTONERVES_SRC") +if _autonerves_src: + print(f"COLAB_GATE_AUTONERVES_SRC set — overlaying {_autonerves_src}") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--no-deps", _autonerves_src] + ) + import importlib + _setup_colab = importlib.import_module("autonerves.setup_colab") + _setup_colab = importlib.reload(_setup_colab) + if not hasattr(_setup_colab, "setup"): print( "SKIP: installed autonerves predates the setup_colab registry " @@ -779,16 +862,69 @@ _setup_colab.setup("autolens", raise_error_if_not_gpu=False, workspace_dir=WS_DI assert os.getcwd() == WS_DIR, f"cwd is {os.getcwd()}, expected {WS_DIR}" assert os.path.isdir(os.path.join(WS_DIR, "config")), "workspace config/ missing" assert os.path.isdir(os.path.join(WS_DIR, "dataset")), "workspace dataset/ missing" +print("setup cell OK: bootstrap installed, workspace cloned, cwd moved") +PYEOF + local setup_rc=0 + COLAB_SIM_WORKSPACE_DIR="$ws_dir" python /tmp/F_driver_setup.py |& tee /tmp/F_driver.log + setup_rc=${PIPESTATUS[0]} -# --- one real notebook cell (the top of imaging/start_here) --- -# -# Load the SAME dataset the current imaging/start_here.py loads: the bundled -# `cosmos_web_ring` JWST example (one of the few datasets that ship committed -# with the workspace, cloned above by setup_colab). The old `dataset/imaging/ -# simple/*.fits` path was never bundled and is no longer used by start_here — -# after the release dataset `-f` leak fix (PyAutoBuild#150) it would only exist -# if simulated at run time, so loading it here crashed FileNotFoundError while -# Check A (which runs start_here.py) passed. + if [ "$setup_rc" -eq 3 ]; then + RESULTS+=("F|SKIP|installed autonerves predates setup_colab registry (next release)") + deactivate + return + fi + if [ "$setup_rc" -ne 0 ]; then + RESULTS+=("F|FAIL|setup cell rc=$setup_rc") + tail_log "Check F driver output" "$(cat /tmp/F_driver.log 2>/dev/null)" + deactivate + return + fi + + # --- the gate: what did the --no-deps bootstrap actually leave behind? --- + # Run from inside the cloned workspace so autonerves resolves its config the + # way a notebook cell does (conf reads the cwd). + step "auditing the bootstrapped environment against Colab's package set" + local gate_rc=0 + (cd "$ws_dir" && "$venv/bin/python" "$VERIFY_INSTALL_DIR/colab_gate.py" verify \ + --manifest-cache "$COLAB_MANIFEST_CACHE" \ + --seed-report "$seed_json" \ + --report-json "$verify_json" \ + --index-args "${PIP_INDEX_ARGS[@]}") |& tee /tmp/F_gate.log + gate_rc=${PIPESTATUS[0]} + + local gate_detail="" + gate_detail=$(python3 -c ' +import json, sys +try: + print(json.load(open(sys.argv[1])).get("detail", "") or "") +except Exception: + print("") +' "$verify_json" 2>/dev/null) + + if [ "$gate_rc" -eq 2 ] || { [ "$gate_rc" -ne 0 ] && [ -z "$gate_detail" ]; }; then + RESULTS+=("F|FAIL|colab gate: verify could not run (rc=$gate_rc)") + tail_log "Check F colab_gate verify output" "$(cat /tmp/F_gate.log 2>/dev/null)" + deactivate + return + fi + if [ "$gate_rc" -ne 0 ]; then + RESULTS+=("F|FAIL|$gate_detail") + tail_log "Check F colab_gate verify output" "$(cat /tmp/F_gate.log 2>/dev/null)" + deactivate + return + fi + + # --- driver part 2: one real notebook cell (the top of imaging/start_here) --- + # + # Load the SAME dataset the current imaging/start_here.py loads: the bundled + # `cosmos_web_ring` JWST example (one of the few datasets that ship committed + # with the workspace, cloned above by setup_colab). The old `dataset/imaging/ + # simple/*.fits` path was never bundled and is no longer used by start_here — + # after the release dataset `-f` leak fix (PyAutoBuild#150) it would only exist + # if simulated at run time, so loading it here crashed FileNotFoundError while + # Check A (which runs start_here.py) passed. + step "running one real notebook cell (al.Imaging.from_fits)" + cat > /tmp/F_driver_cell.py <<'PYEOF' import autolens as al dataset = al.Imaging.from_fits( @@ -799,19 +935,17 @@ dataset = al.Imaging.from_fits( ) print(f"cell OK: loaded imaging dataset, shape {dataset.data.shape_native}") PYEOF - local drv_rc=0 - COLAB_SIM_WORKSPACE_DIR="$ws_dir" python /tmp/F_driver.py |& tee /tmp/F_driver.log - drv_rc=${PIPESTATUS[0]} + local cell_rc=0 + (cd "$ws_dir" && python /tmp/F_driver_cell.py) |& tee /tmp/F_cell.log + cell_rc=${PIPESTATUS[0]} deactivate - if [ "$drv_rc" -eq 0 ]; then - RESULTS+=("F|PASS|Colab bootstrap + workspace clone + notebook cell") - elif [ "$drv_rc" -eq 3 ]; then - RESULTS+=("F|SKIP|installed autonerves predates setup_colab registry (next release)") + if [ "$cell_rc" -eq 0 ]; then + RESULTS+=("F|PASS|$gate_detail") else - RESULTS+=("F|FAIL|driver rc=$drv_rc") - tail_log "Check F driver output" "$(cat /tmp/F_driver.log 2>/dev/null)" + RESULTS+=("F|FAIL|notebook cell rc=$cell_rc") + tail_log "Check F notebook cell output" "$(cat /tmp/F_cell.log 2>/dev/null)" fi } @@ -881,6 +1015,7 @@ if [ -n "$REPORT_JSON" ]; then VI_READY="$ready_bool" VI_VERSION="$TARGET_VERSION" VI_CHECK_B_VERSION="$CHECK_B_VERSION" \ VI_REPORT_JSON="$REPORT_JSON" \ VI_INDEX="$vi_index" \ + VI_F_GATE_SEED="$F_GATE_SEED_JSON" VI_F_GATE_VERIFY="$F_GATE_VERIFY_JSON" \ python3 -c ' import datetime, json, os, sys checks = [] @@ -890,6 +1025,28 @@ for line in sys.stdin: continue parts = (line.split("|", 2) + ["", "", ""])[:3] checks.append({"check": parts[0], "status": parts[1], "detail": parts[2]}) + +# Check F carries the Colab gate report as a nested "colab_gate" key on its own +# row. Additive only: every existing key and the shape of "checks" (a list of +# {check,status,detail}) are untouched, because heart/readiness.py and +# heart/validate.py parse this file and must keep working unchanged. +def _read(var): + path = os.environ.get(var) or "" + if not path or not os.path.isfile(path): + return None + try: + with open(path) as handle: + return json.load(handle) + except (OSError, ValueError): + return None + +gate = {k: v for k, v in (("seed", _read("VI_F_GATE_SEED")), + ("verify", _read("VI_F_GATE_VERIFY"))) if v is not None} +if gate: + for entry in checks: + if entry["check"] == "F": + entry["colab_gate"] = gate + out = { "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(), "ready": os.environ["VI_READY"] == "true", diff --git a/heart/config/colab_gate.yaml b/heart/config/colab_gate.yaml new file mode 100644 index 0000000..e16358f --- /dev/null +++ b/heart/config/colab_gate.yaml @@ -0,0 +1,20 @@ +# heart/config/colab_gate.yaml — accepted misses for the Colab gate (check F). +# +# The gate FAILs when a module the libraries import unguarded is not available +# in a simulated Colab environment, when a headline `autofit` search cannot be +# constructed, or when a declared dependency is both absent on Colab and +# actually imported. Each entry below downgrades ONE of those FAILs to a WARN, +# and the reason travels into the JSON report so the exemption is never silent. +# +# An entry is a deliberate, argued decision — "Colab genuinely cannot carry +# this and the notebooks never reach it" — not a way to quieten a real hole in +# the `--no-deps` bootstrap. The fix for a missing dependency is to add it to +# `_SHARED_EXTRAS` in `autonerves/setup_colab.py`, not to list it here. +# +# Format: +# +# accepted_missing: +# - name: +# reason: + +accepted_missing: [] diff --git a/skills/verify_install/verify_install.md b/skills/verify_install/verify_install.md index 4910912..66bb80d 100644 --- a/skills/verify_install/verify_install.md +++ b/skills/verify_install/verify_install.md @@ -18,13 +18,52 @@ about cleanup if they ran with `--keep`. | C | The conda flow from `installation/conda.rst` works end-to-end (`conda create … python=3.12` → `pip install autolens` → clone workspace → run `welcome.py` + `start_here.py`). | | D | `pip install "autolens[optional]"` resolves cleanly and imports. | | E | `pip install autolens==2026.2.26.4` (a yanked release the docs reference) still installs on `python3.12` by explicit pin. | -| F | The Colab bootstrap path end-to-end: a venv emulating Colab's preinstalled env (`autolens` + `jax` from PyPI), a fake `google.colab` package so the on-Colab branch activates, then the injected setup cell verbatim (`pip install autoconf --no-deps` → `setup_colab.setup("autolens")` → workspace clone at the release tag) and one real notebook cell (`al.Imaging.from_fits` on `dataset/imaging/simple`). SKIPs while the installed autoconf predates the `setup_colab` registry (self-activates at the next release). | +| F | The **Colab gate**. A `python3.12` venv (Colab's interpreter) is seeded from Google's own Colab package manifest (`googlecolab/backend-info`'s `pip-freeze.txt`): the with-deps closure of the PyAuto stack is resolved but **not** installed, and only the part of it Colab also ships is installed, at Colab's pinned versions. The injected setup cell then runs verbatim on top (`pip install autonerves --no-deps` → `setup_colab.setup("autolens")` → `--no-deps` bootstrap → workspace clone at the release tag), and the gate audits what that bootstrap left: it walks every declared requirement of the five installed libraries, AST-scans **every** `import` in their source at any depth, and imports each third-party module for real. An unguarded import of a module Colab will not have is **FAIL**; so is a headline `af.Emcee()` / `af.DynestyStatic()` / `af.Nautilus()` / `af.LBFGS()` that cannot be constructed, and a declared dependency that is both absent on Colab and actually imported. Version conflicts, guarded imports and never-imported gaps are reported as WARNs. A real notebook cell (`al.Imaging.from_fits` on the bundled `dataset/imaging/cosmos_web_ring`) runs last. SKIPs while the installed `autonerves` predates the `setup_colab` registry. | -Check B requires `python3.11`, `python3.12`, and `python3.13`; Check E requires -`python3.12`. A missing required interpreter is **FAIL**. Optional host +Check B requires `python3.11`, `python3.12`, and `python3.13`; Checks E and F +require `python3.12`. A missing required interpreter is **FAIL**. Optional host capabilities such as conda remain **SKIP** when unavailable and do not count toward overall failure. +### What Check F does and does not cover + +Check F covers **Colab's package set and Colab's interpreter** — the two things +that make a notebook die there and nowhere else. A dependency imported lazily +inside a function leaves `import autolens` working and only detonates on the +line that reaches it, and the workspace smoke gate cannot see those either (it +runs at `PYAUTO_TEST_MODE=2` and never constructs a sampler). That is the gap +this check closes; the fix for anything it finds is normally a new entry in +`_SHARED_EXTRAS` in `autonerves/setup_colab.py`. + +It does **not** cover: + +- **the GPU** — no accelerator is present, and `setup` is called with + `raise_error_if_not_gpu=False`; +- **Colab's operating system, CUDA stack or `apt` packages** — only the pip + package set is reproduced; +- **the manifest's lag** — `googlecolab/backend-info` is refreshed when Google + cuts an image, so it trails the live runtime by a day or two. A failure + caused purely by a version Colab shipped yesterday is possible; the report + always names the manifest's source (`live`, `cache` or the vendored + snapshot) and date so the evidence can be dated. + +The manifest is fetched live, cached at `$HEART_STATE_DIR/colab_pip_freeze.txt`, +and falls back to `heart/checks/colab_pip_freeze.snapshot.txt` when both are +unavailable. Deliberate exemptions live in `heart/config/colab_gate.yaml` +(`accepted_missing`), each with a written reason that travels into the report. + +**`COLAB_GATE_AUTONERVES_SRC`** (development / witness runs only) installs a +path or requirement `--no-deps` over the released `autonerves` immediately +after the setup cell's own bootstrap install. It exists because the package +list the gate measures lives in `autonerves/setup_colab.py`, so a fix to it +cannot otherwise be rehearsed until it is on PyPI: + +```bash +COLAB_GATE_AUTONERVES_SRC=/path/to/PyAutoNerves pyauto-heart verify_install F +``` + +Never set it in CI — a release gate must read the wheels that are about to ship. + ## Running without a skill harness The script is self-contained and runs from any shell. The canonical entry point is @@ -93,7 +132,10 @@ If the user wants to inspect a specific environment, re-run the relevant check w ## Files - `PyAutoHeart/heart/checks/verify_install.sh` — the runnable script; source of truth for - what each check does. Owned by PyAutoHeart, which owns all release-readiness checking; the + what each check does. +- `PyAutoHeart/heart/checks/colab_gate.py` — Check F's `seed` / `verify` gate, plus + `colab_pip_freeze.snapshot.txt` (the vendored Colab manifest) and + `PyAutoHeart/heart/config/colab_gate.yaml` (accepted misses). Owned by PyAutoHeart, which owns all release-readiness checking; the `--report-json` sidecar it writes feeds `pyauto-heart readiness`. - `verify_install.md` — this file; explains the skill and how to invoke it. diff --git a/tests/test_colab_gate.py b/tests/test_colab_gate.py new file mode 100644 index 0000000..0194d7b --- /dev/null +++ b/tests/test_colab_gate.py @@ -0,0 +1,659 @@ +"""tests/test_colab_gate.py — the Colab package-set gate's pure rules. + +Every test here is offline and venv-free: the gate is deliberately split so +manifest parsing, the closure intersection, the requirement walk, the AST +import scan and the verdict are pure functions the suite can pin. The parts +that need a network or a throwaway venv (the live fetch, `pip --dry-run`, the +import probe) are exercised by running `verify_install F` for real. +""" + +from __future__ import annotations + +import json +import textwrap + +import pytest + +from heart.checks import colab_gate as cg + + +# -------------------------------------------------------------------------- +# manifest parsing +# -------------------------------------------------------------------------- + + +MANIFEST_SAMPLE = textwrap.dedent( + """\ + # Do not modify this file directly; it is generated by a script. + # $ python3 -m pip freeze + + absl-py==1.4.0 + SQLAlchemy==2.0.52 + typing_extensions==4.16.0 + requests[socks]==2.32.3 + torch @ https://download.pytorch.org/whl/cu124/torch-2.8.0.whl + some-vcs-package @ git+https://github.com/x/y@main + -e /content/editable + unpinned-package + optax==0.2.8 + """ +) + + +def test_manifest_parsing_keeps_pins_and_normalises_names(): + packages = cg.parse_manifest(MANIFEST_SAMPLE) + + assert packages["absl-py"] == "1.4.0" + assert packages["optax"] == "0.2.8" + # PEP 503: case folded, separators unified. + assert packages["sqlalchemy"] == "2.0.52" + assert packages["typing-extensions"] == "4.16.0" + # Extras are not part of the name. + assert packages["requests"] == "2.32.3" + + +def test_manifest_parsing_skips_everything_that_is_not_a_pin(): + packages = cg.parse_manifest(MANIFEST_SAMPLE) + + # Direct references and VCS installs name no installable version. + assert "torch" not in packages + assert "some-vcs-package" not in packages + # Editables, comments, blanks and bare names are not pins either. + assert "editable" not in packages + assert "unpinned-package" not in packages + assert not any(name.startswith("#") for name in packages) + + +def test_vendored_snapshot_parses_and_carries_its_fetch_date(): + text = cg.SNAPSHOT_PATH.read_text() + packages = cg.parse_manifest(text) + + assert len(packages) > 500 + assert cg._snapshot_date(text) is not None + # The package set the gate exists to reason about. + assert "optax" in packages and "xxhash" in packages and "jax" in packages + for absent in ("corner", "emcee", "dynesty", "blackjax", "nautilus-sampler"): + assert absent not in packages, f"{absent} is not shipped by Colab" + + +# -------------------------------------------------------------------------- +# manifest fallback order +# -------------------------------------------------------------------------- + + +def _failing_fetch(): + raise OSError("no network in the unit suite") + + +def test_manifest_prefers_the_live_fetch_and_refreshes_the_cache(tmp_path): + cache = tmp_path / "colab_pip_freeze.txt" + + packages, source, date, notes = cg.load_manifest( + cache, fetcher=lambda: MANIFEST_SAMPLE + ) + + assert source == "live" + assert date is not None + assert notes == [] + assert packages["optax"] == "0.2.8" + # The live copy is cached so the next offline run reads today's manifest + # rather than falling all the way back to the vendored snapshot. + assert cg.parse_manifest(cache.read_text())["optax"] == "0.2.8" + + +def test_manifest_falls_back_to_the_cache_when_the_fetch_fails(tmp_path): + cache = tmp_path / "colab_pip_freeze.txt" + cache.write_text("cached-only==9.9.9\n") + + packages, source, date, notes = cg.load_manifest(cache, fetcher=_failing_fetch) + + assert source == "cache" + assert packages == {"cached-only": "9.9.9"} + assert date is not None + assert any("live fetch failed" in note for note in notes) + + +def test_manifest_falls_back_to_the_snapshot_when_there_is_no_cache(tmp_path): + snapshot = tmp_path / "snapshot.txt" + snapshot.write_text( + "# source: googlecolab/backend-info pip-freeze.txt fetched 2026-09-15\n" + "snapshot-only==1.2.3\n" + ) + + packages, source, date, notes = cg.load_manifest( + tmp_path / "missing-cache.txt", snapshot_path=snapshot, fetcher=_failing_fetch + ) + + assert source == "snapshot" + assert date == "2026-09-15" + assert packages == {"snapshot-only": "1.2.3"} + + +def test_an_empty_live_manifest_is_not_trusted(tmp_path): + """A 200 with an empty body must not seed an empty Colab.""" + packages, source, _, notes = cg.load_manifest( + tmp_path / "missing-cache.txt", fetcher=lambda: "" + ) + + assert source == "snapshot" + assert len(packages) > 500 + assert any("zero pins" in note for note in notes) + + +# -------------------------------------------------------------------------- +# closure intersection +# -------------------------------------------------------------------------- + + +def test_closure_intersection_keeps_colab_packages_and_drops_the_pyauto_stack(): + closure = { + "autolens": "2026.9.1.1", + "autofit": "2026.9.1.1", + "autonerves": "2026.9.1.1", + "autoarray": "2026.9.1.1", + "autogalaxy": "2026.9.1.1", + "numpy": "2.3.0", + "optax": "0.3.0", + "corner": "2.2.2", + } + manifest = {"numpy": "2.1.3", "optax": "0.2.8", "jax": "0.11.1"} + + assert cg.colab_intersection(closure, manifest) == ["numpy", "optax"] + + +def test_pyauto_packages_are_never_seeded_even_if_colab_shipped_them(): + """Defensive: the setup cell owns the PyAuto install, the seed never does.""" + manifest = {"autolens": "2020.1.1", "numpy": "2.1.3"} + + assert cg.colab_intersection({"autolens": "2026.9.1.1", "numpy": "2.3.0"}, manifest) == [ + "numpy" + ] + + +def test_dry_run_report_is_parsed_out_of_noisy_pip_stdout(): + payload = { + "install": [ + {"metadata": {"name": "NumPy", "version": "2.3.0"}}, + {"metadata": {"name": "corner", "version": "2.2.2"}}, + ] + } + stdout = "WARNING: a resolver warning reached stdout\n" + json.dumps(payload) + + assert cg.parse_dry_run_report(stdout) == {"numpy": "2.3.0", "corner": "2.2.2"} + + +# -------------------------------------------------------------------------- +# requirement walk +# -------------------------------------------------------------------------- + + +@pytest.fixture +def fake_dists(): + """A fabricated distribution graph: requires + installed versions.""" + requires = { + "autofit": [ + "numpy>=2.0", + "corner", + "xxhash<=3.4.1", + "dynesty==2.1.5", + 'optax>=0.2.5; sys_platform != "darwin"', + 'anesthetic>=2.9.0; extra == "optional"', + 'windows-only-thing; sys_platform == "win32"', + ], + "numpy": [], + "xxhash": [], + } + installed = {"autofit": "2026.9.1.1", "numpy": "2.1.3", "xxhash": "4.0.1"} + return requires, installed + + +def walk(fake_dists, manifest, install=None): + requires, installed = fake_dists + return cg.walk_requirements( + ["autofit"], + manifest, + installed_version=lambda name: installed.get(cg.normalise(name)), + requirements=lambda name: requires.get(cg.normalise(name), []), + install=install, + ) + + +def test_walk_separates_colab_provided_missing_and_conflicting(fake_dists): + manifest = {"numpy": "2.1.3", "xxhash": "4.0.1", "optax": "0.2.8"} + + result = walk(fake_dists, manifest) + + missing = {entry["name"] for entry in result["missing_declared"]} + requested = {entry["name"] for entry in result["colab_installed"]} + conflicts = {entry["name"]: entry for entry in result["version_conflict"]} + + # Absent AND not shipped by Colab: the hole the --no-deps bootstrap leaves. + assert missing == {"corner", "dynesty"} + # Absent but Colab ships it: install Colab's pin, not the declared floor. + assert requested == {"optax"} + assert result["colab_installed"][0]["version"] == "0.2.8" + # Installed, but outside what autofit declares. + assert set(conflicts) == {"xxhash"} + assert conflicts["xxhash"]["installed"] == "4.0.1" + assert conflicts["xxhash"]["specifier"] == "<=3.4.1" + assert conflicts["xxhash"]["required_by"] == ["autofit"] + + +def test_walk_skips_extra_markers_and_false_environment_markers(fake_dists): + result = walk(fake_dists, {"numpy": "2.1.3", "xxhash": "4.0.1", "optax": "0.2.8"}) + + names = {entry["name"] for entry in result["missing_declared"]} + # Behind `extra == "optional"`: not part of a plain `pip install autofit`. + assert "anesthetic" not in names + # Behind a marker that is false on Colab (always linux). + assert "windows-only-thing" not in names + + +def test_walk_records_who_declared_a_missing_requirement(fake_dists): + result = walk(fake_dists, {}) + + corner = next(e for e in result["missing_declared"] if e["name"] == "corner") + assert corner["required_by"] == ["autofit"] + + +def test_walk_continues_through_packages_it_installs_from_colab(): + requires = { + "autofit": ["mid-package"], + "mid-package": ["leaf-package"], + "leaf-package": [], + } + installed = {"autofit": "1.0"} + installs: list[list[str]] = [] + + def install(pins): + installs.append(list(pins)) + for pin in pins: + name, _, version = pin.partition("==") + installed[cg.normalise(name)] = version + return True, "" + + result = cg.walk_requirements( + ["autofit"], + {"mid-package": "3.0"}, + installed_version=lambda name: installed.get(cg.normalise(name)), + requirements=lambda name: requires.get(cg.normalise(name), []), + install=install, + ) + + # mid-package installed from Colab's pin, then walked, exposing leaf-package + # — which Colab does NOT ship, so it surfaces as a real hole. + assert installs == [["mid-package==3.0"]] + assert [e["name"] for e in result["colab_installed"]] == ["mid-package"] + assert [e["name"] for e in result["missing_declared"]] == ["leaf-package"] + assert result["rounds"] >= 2 + + +def test_walk_reports_a_colab_pin_that_will_not_install(): + def install(pins): + return False, "ERROR: no matching distribution" + + result = cg.walk_requirements( + ["autofit"], + {"broken-package": "1.0"}, + installed_version=lambda name: {"autofit": "1.0"}.get(cg.normalise(name)), + requirements=lambda name: ["broken-package"] if name == "autofit" else [], + install=install, + ) + + assert [e["name"] for e in result["missing_declared"]] == ["broken-package"] + assert "no matching distribution" in result["missing_declared"][0]["note"] + + +# -------------------------------------------------------------------------- +# AST import scan +# -------------------------------------------------------------------------- + + +def test_scanner_finds_imports_at_any_depth_and_marks_guarding(): + source = textwrap.dedent( + """\ + import numpy as np + from astropy.io import fits + + + def plot(samples): + import corner # the lazy import that detonates on Colab + + return corner.corner(samples) + + + def optional(): + try: + import zeus + except ImportError: + zeus = None + return zeus + + + def broadly_guarded(): + try: + import hmf + except Exception: + return None + + + def bare_guard(): + try: + import colossus + except: # noqa: E722 + return None + """ + ) + + found = cg.scan_imports_source(source, "autofit/plot.py") + by_name = {name: (line, guarded) for name, line, guarded in found} + + assert by_name["numpy"] == (1, False) + # `from a.b import c` is attributed to the top-level distribution module. + assert by_name["astropy"] == (2, False) + # Function-level and unguarded: exactly the corner case. + assert by_name["corner"] == (6, False) + # try/except ImportError, try/except Exception and a bare except all guard. + assert by_name["zeus"][1] is True + assert by_name["hmf"][1] is True + assert by_name["colossus"][1] is True + + +def test_scanner_excludes_relative_imports(): + found = cg.scan_imports_source( + "from . import mask\nfrom ..operators import transformer\n", "autoarray/x.py" + ) + + assert found == [] + + +def test_scanner_does_not_treat_except_or_finally_bodies_as_guarded(): + source = textwrap.dedent( + """\ + try: + import fast_thing + except ImportError: + import slow_thing + else: + import else_thing + finally: + import finally_thing + """ + ) + + guarded = {name: g for name, _, g in cg.scan_imports_source(source, "x.py")} + + assert guarded["fast_thing"] is True + # A fallback import is the thing that must work, so it is not guarded. + assert guarded["slow_thing"] is False + assert guarded["else_thing"] is False + assert guarded["finally_thing"] is False + + +def test_scanner_survives_a_file_it_cannot_parse(): + assert cg.scan_imports_source("def broken(:\n", "x.py") == [] + + +def test_package_scan_drops_stdlib_pyauto_and_dunder_future(tmp_path): + package = tmp_path / "autofit" + (package / "sub").mkdir(parents=True) + (package / "__init__.py").write_text( + "from __future__ import annotations\n" + "import os\nimport json\nimport pathlib\n" + "import autoarray\nimport autolens\n" + "import numpy\n" + ) + (package / "sub" / "deep.py").write_text("def f():\n import corner\n") + + sites = cg.scan_package_imports({"autofit": package}) + + assert set(sites) == {"numpy", "corner"} + # The site is reported repo-relative so a FAIL line points at a real file. + assert sites["corner"][0]["file"] == "autofit/sub/deep.py" + assert sites["corner"][0]["line"] == 2 + assert sites["corner"][0]["guarded"] is False + + +# -------------------------------------------------------------------------- +# verdict +# -------------------------------------------------------------------------- + + +def _verdict(**kwargs): + base = dict( + missing_declared=[], + version_conflict=[], + import_failures_unguarded=[], + import_failures_guarded=[], + constructor_failures=[], + ) + base.update(kwargs) + return cg.verdict(**base) + + +def test_clean_environment_passes(): + result = _verdict() + + assert result["ok"] is True + assert result["fails"] == [] + + +def test_an_unguarded_import_failure_fails_the_gate(): + result = _verdict( + import_failures_unguarded=[ + { + "module": "corner", + "error": "ModuleNotFoundError: No module named 'corner'", + "sites": [ + { + "file": "autofit/non_linear/plot/samples_plotters.py", + "line": 95, + "guarded": False, + } + ], + } + ], + imported_modules=["corner"], + ) + + assert result["ok"] is False + assert result["fails"] == [ + "corner (autofit/non_linear/plot/samples_plotters.py:95)" + ] + + +def test_a_guarded_import_failure_is_only_a_warning(): + result = _verdict( + import_failures_guarded=[ + {"module": "hmf", "error": "ModuleNotFoundError", "sites": [{}]} + ] + ) + + assert result["ok"] is True + assert any("hmf" in warn for warn in result["warns"]) + + +def test_a_constructor_failure_fails_the_gate(): + result = _verdict( + constructor_failures=[ + {"name": "Emcee", "error": "ModuleNotFoundError: No module named 'emcee'"} + ] + ) + + assert result["ok"] is False + assert "af.Emcee()" in result["fails"][0] + + +def test_a_missing_declared_dependency_fails_only_when_it_is_imported(): + imported = _verdict( + missing_declared=[ + {"name": "corner", "required_by": ["autofit"], "specifier": ""} + ], + imported_modules=["corner", "numpy"], + ) + never_imported = _verdict( + missing_declared=[ + {"name": "gprof2dot", "required_by": ["autofit"], "specifier": ""} + ], + imported_modules=["corner", "numpy"], + ) + + assert imported["ok"] is False + assert "corner" in imported["fails"][0] + assert never_imported["ok"] is True + assert any("gprof2dot" in warn for warn in never_imported["warns"]) + + +def test_missing_dependency_matching_handles_dist_vs_module_names(): + """`scikit-learn` is imported as `sklearn`; the FAIL rule must still fire.""" + result = _verdict( + missing_declared=[ + {"name": "scikit-learn", "required_by": ["autoarray"], "specifier": ""} + ], + imported_modules=["sklearn"], + ) + + assert result["ok"] is False + + +def test_version_conflicts_and_closure_gaps_are_warnings_only(): + result = _verdict( + version_conflict=[ + { + "name": "xxhash", + "installed": "4.0.1", + "specifier": "<=3.4.1", + "required_by": ["autofit"], + } + ], + closure_not_on_colab=["corner", "emcee"], + ) + + assert result["ok"] is True + assert any("xxhash" in warn for warn in result["warns"]) + assert any("corner" in warn for warn in result["warns"]) + + +def test_accepted_missing_downgrades_a_fail_to_a_warning(): + unguarded = [ + { + "module": "corner", + "error": "ModuleNotFoundError", + "sites": [{"file": "autofit/plot.py", "line": 95, "guarded": False}], + } + ] + + blocking = _verdict(import_failures_unguarded=unguarded, imported_modules=["corner"]) + accepted = _verdict( + import_failures_unguarded=unguarded, + imported_modules=["corner"], + accepted_missing=[{"name": "corner", "reason": "documented exemption"}], + ) + + assert blocking["ok"] is False + assert accepted["ok"] is True + assert accepted["accepted"] == [ + {"name": "corner", "kind": "unguarded import", "reason": "documented exemption"} + ] + assert any("documented exemption" in warn for warn in accepted["warns"]) + + +def test_accepted_missing_is_matched_on_the_normalised_name(): + result = _verdict( + missing_declared=[ + {"name": "nautilus-sampler", "required_by": ["autofit"], "specifier": ""} + ], + imported_modules=["nautilus"], + accepted_missing=[{"name": "Nautilus_Sampler", "reason": "why"}], + ) + + assert result["ok"] is True + + +def test_detail_line_names_the_first_failures_then_counts_the_rest(): + fails = [f"pkg{i} (autofit/x.py:{i})" for i in range(6)] + + detail = cg.format_detail(fails) + + assert detail.startswith("colab gate: pkg0 (autofit/x.py:0); pkg1") + assert detail.endswith("+3 more") + assert "|" not in detail, "the RESULTS row is pipe-delimited" + + +# -------------------------------------------------------------------------- +# config file +# -------------------------------------------------------------------------- + + +def test_shipped_config_is_an_empty_accepted_list(): + config = cg.load_config(cg.CONFIG_PATH) + + assert config["accepted_missing"] == [] + + +def test_config_is_readable_without_pyyaml(tmp_path): + """`verify` runs inside the simulated venv, which need not have PyYAML.""" + path = tmp_path / "colab_gate.yaml" + path.write_text( + "# a comment\n" + "accepted_missing:\n" + " - name: corner\n" + " reason: exempt for a reason\n" + " - name: emcee\n" + " reason: another reason\n" + ) + + parsed = cg._parse_config_fallback(path.read_text()) + + assert parsed["accepted_missing"] == [ + {"name": "corner", "reason": "exempt for a reason"}, + {"name": "emcee", "reason": "another reason"}, + ] + + +def test_missing_config_is_not_an_error(tmp_path): + assert cg.load_config(tmp_path / "nope.yaml") == {"accepted_missing": []} + + +def test_walk_completes_a_no_deps_install_from_colabs_own_package_set(): + """The IPython/pickleshare regression. + + The seed installs the closure ∩ manifest `--no-deps`, so a seeded package's + own runtime dependencies only land if the PyAuto closure happened to need + them too. Colab ships IPython 7.34.0, which needs pickleshare, and nothing + in the autolens closure does — so `import jax` (which reaches IPython via + its Colab debugger) died on a package Colab has always had. Colab is + internally consistent; the simulation has to be too. + """ + requires = {"ipython": ["pickleshare", "jedi>=0.16"], "pickleshare": [], "jedi": []} + installed = {"ipython": "7.34.0"} + manifest = {"pickleshare": "0.7.5", "jedi": "0.19.2"} + + def install(pins): + for pin in pins: + name, _, version = pin.partition("==") + installed[cg.normalise(name)] = version + return True, "" + + result = cg.walk_requirements( + ["ipython"], + manifest, + installed_version=lambda name: installed.get(cg.normalise(name)), + requirements=lambda name: requires.get(cg.normalise(name), []), + install=install, + ) + + assert sorted(e["name"] for e in result["colab_installed"]) == ["jedi", "pickleshare"] + assert installed["pickleshare"] == "0.7.5" + # Nothing absent from the manifest is ever installed by the completion pass. + assert result["missing_declared"] == [] + + +def test_module_dist_candidates_inverts_the_alias_table(): + # A module whose distribution is named differently still has to be + # recognisable in the Colab manifest. + assert "scikit-learn" in cg.module_dist_candidates("sklearn") + assert "pyyaml" in cg.module_dist_candidates("yaml") + assert "nautilus-sampler" in cg.module_dist_candidates("nautilus") + # The ordinary case: the module name is the distribution name. + assert cg.module_dist_candidates("numba") == {"numba"} diff --git a/tests/test_verify_install_script.py b/tests/test_verify_install_script.py index f572a2c..e375ce4 100644 --- a/tests/test_verify_install_script.py +++ b/tests/test_verify_install_script.py @@ -64,12 +64,13 @@ def test_bash_syntax(): assert result.returncode == 0, result.stderr -def test_help_lists_all_checks_including_colab_simulation(): +def test_help_lists_all_checks_including_the_colab_gate(): result = run("--help") assert result.returncode == 0 for letter in "ABCDEF": assert f"\n {letter} " in result.stdout, f"check {letter} missing from help" - assert "Colab simulation" in result.stdout + assert "Colab gate" in result.stdout + assert "googlecolab/backend-info" in result.stdout def test_unknown_argument_rejected(): @@ -298,3 +299,194 @@ def test_check_b_asserts_the_unpinned_install_is_refused(): assert "verify_install_unpinned_refusal" in body # A successful unpinned install below the floor is the bug returning. assert "the sub-floor backtrack is back" in body + + +# ----- check F: the Colab package-set gate ----------------------------------- + + +def check_f_body(): + text = SCRIPT.read_text() + return text[text.index("check_f() {") : text.index("# ----- runner -----")] + + +def test_check_f_uses_python_312_because_colab_does(): + body = check_f_body() + + assert 'if ! command -v python3.12 > /dev/null 2>&1; then' in body + assert 'RESULTS+=("F|FAIL|python3.12 not found")' in body + assert 'if ! make_venv "$venv" python3.12; then' in body + # The old default-python venv is gone: seeding Colab's pins against a + # different interpreter would resolve the wrong wheels. + assert 'make_venv "$venv" python3;' not in body + + +def test_check_f_no_longer_installs_the_stack_with_dependencies(): + """The bug this check existed to hide. + + `pip install jax` WITH deps left corner/optax/xxhash/blackjax in + the venv before the setup cell ran, so the cell's real `--no-deps` install + could never be seen to miss one. + """ + body = check_f_body() + + assert 'pip install "${PIP_INDEX_ARGS[@]}" "${f_targets[@]}" jax' not in body + assert '"${f_targets[@]}" jax' not in body + assert "emulating Colab's preinstalled env" not in body + + +def test_check_f_seeds_and_verifies_through_colab_gate(): + body = check_f_body() + + assert '"$VERIFY_INSTALL_DIR/colab_gate.py" seed' in body + assert '"$VERIFY_INSTALL_DIR/colab_gate.py" verify' in body + # Run with the simulated venv's interpreter, or importlib.metadata and the + # import probe would see the host environment instead. + assert body.count('"$venv/bin/python" "$VERIFY_INSTALL_DIR/colab_gate.py"') == 2 + assert '--manifest-cache "$COLAB_MANIFEST_CACHE"' in body + assert (ROOT / "heart/checks/colab_gate.py").is_file() + assert (ROOT / "heart/checks/colab_pip_freeze.snapshot.txt").is_file() + + +def test_check_f_runs_the_gate_between_the_setup_cell_and_the_notebook_cell(): + body = check_f_body() + + setup_cell = body.index("F_driver_setup.py") + gate = body.index('colab_gate.py" verify') + notebook_cell = body.index("F_driver_cell.py") + + assert setup_cell < gate < notebook_cell + # The setup cell is still the injected cell verbatim, and the notebook cell + # still loads the bundled dataset. + assert "import google.colab" in body + assert 'pip", "install", "autonerves", "--no-deps"' in body + assert "al.Imaging.from_fits" in body + assert "dataset/imaging/cosmos_web_ring/data.fits" in body + + +def test_check_f_keeps_the_fake_google_colab_stub(): + body = check_f_body() + + assert '"$site/google/colab/__init__.py"' in body + assert '"$site/google/colab/output.py"' in body + + +def test_check_f_preserves_the_skip_exit_code(): + body = check_f_body() + + assert "sys.exit(3)" in body + assert 'if [ "$setup_rc" -eq 3 ]; then' in body + assert 'RESULTS+=("F|SKIP|installed autonerves predates setup_colab registry' in body + + +def test_autonerves_source_override_is_wired_and_documented(): + body = check_f_body() + help_result = run("--help") + + assert 'os.environ.get("COLAB_GATE_AUTONERVES_SRC")' in body + assert '"--no-deps", _autonerves_src' in body + assert "COLAB_GATE_AUTONERVES_SRC" in help_result.stdout + assert "COLAB_GATE_AUTONERVES_SRC" in ( + ROOT / "skills/verify_install/verify_install.md" + ).read_text() + + +def test_sidecar_nests_the_gate_report_under_check_f_without_changing_its_shape(): + text = SCRIPT.read_text() + + assert 'VI_F_GATE_SEED="$F_GATE_SEED_JSON"' in text + assert 'VI_F_GATE_VERIFY="$F_GATE_VERIFY_JSON"' in text + assert 'entry["colab_gate"] = gate' in text + # The keys readiness.py and validate.py parse are untouched. + for key in ('"check": parts[0]', '"status": parts[1]', '"detail": parts[2]'): + assert key in text + + +def sidecar_writer_source(): + """The `python3 -c '...'` sidecar writer, lifted out of the script. + + Running the real writer (rather than hand-building a fixture) is the only + way to prove the shape readiness.py and validate.py consume is unchanged. + """ + text = SCRIPT.read_text() + start = text.index(" python3 -c '") + len(" python3 -c '") + end = text.index("\n'\n", start) + return text[start:end] + + +def test_sidecar_still_parses_through_readiness_with_the_gate_report(tmp_path): + import json + import os + + from heart import readiness + + seed = tmp_path / "seed.json" + verify = tmp_path / "verify.json" + seed.write_text(json.dumps({"phase": "seed", "manifest_source": "live"})) + verify.write_text(json.dumps({ + "phase": "verify", + "ok": True, + "detail": "Colab manifest live 2026-09-15; 61 Colab-provided, 9 extras, 74 imports probed", + "fails": [], + "warns": ["xxhash 4.0.1 outside <=3.4.1 required by autofit"], + })) + out = tmp_path / "verify_install.json" + + env = dict(os.environ) + env.update({ + "VI_READY": "true", + "VI_VERSION": "2026.9.1.1", + "VI_CHECK_B_VERSION": "2026.9.1.1", + "VI_REPORT_JSON": str(out), + "VI_INDEX": "testpypi", + "VI_F_GATE_SEED": str(seed), + "VI_F_GATE_VERIFY": str(verify), + }) + rows = "A|PASS|pip install\nF|PASS|Colab manifest live 2026-09-15\n" + result = subprocess.run( + ["python3", "-c", sidecar_writer_source()], + input=rows, capture_output=True, text=True, env=env, + ) + assert result.returncode == 0, result.stderr + + sidecar = json.loads(out.read_text()) + + # Every key the consumers read is still there, in the same shape. + assert set(sidecar) >= {"ts", "ready", "version", "check_b_version", "index", "checks"} + assert sidecar["ready"] is True + assert sidecar["index"] == "testpypi" + assert [c["check"] for c in sidecar["checks"]] == ["A", "F"] + assert sidecar["checks"][0] == {"check": "A", "status": "PASS", "detail": "pip install"} + + # The gate report is nested on F's row only, and is additive. + f_row = sidecar["checks"][1] + assert f_row["check"] == "F" and f_row["status"] == "PASS" + assert f_row["colab_gate"]["seed"]["manifest_source"] == "live" + assert f_row["colab_gate"]["verify"]["ok"] is True + assert "colab_gate" not in sidecar["checks"][0] + + # readiness's own FAIL-extraction expression, run against the new shape. + failed = [ + str(c.get("check")) + for c in sidecar["checks"] + if isinstance(c, dict) and str(c.get("status")).upper() == "FAIL" + ] + assert failed == [] + + # And a FAILing F row still reaches readiness as a RED reason naming F. + env["VI_READY"] = "false" + rows_fail = "A|PASS|ok\nF|FAIL|colab gate: corner (autofit/plot.py:95)\n" + subprocess.run( + ["python3", "-c", sidecar_writer_source()], + input=rows_fail, capture_output=True, text=True, env=env, check=True, + ) + red_sidecar = json.loads(out.read_text()) + snapshot = { + "ts": "2026-09-15T00:00:00+00:00", + "verify_install": red_sidecar, + } + result = readiness.compute(snapshot) + assert result["verdict"] == "red" + assert any( + "install verification FAILED" in reason and "F" in reason + for reason in result["red_reasons"] + ) From 6ca0a9970c2ee514ed1a663e41a2b6cb7936cb70 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Tue, 15 Sep 2026 20:55:58 +0100 Subject: [PATCH 2/2] feat(heart): accept colossus/hmf/mcp in the Colab gate; witness PASS with Nerves#167 + extras Three of the five library-side holes are not notebook paths: colossus and hmf (autolens/lens/los.py line-of-sight tooling, test/dev extra) and mcp (autofit/mcp/server.py, the MCP server entry point). They are accepted with written reasons in heart/config/colab_gate.yaml. The other two (jax_zero_contour, zeus-mcmc) are real Colab breakages and join _SHARED_EXTRAS on the PyAutoNerves branch. Check F with that branch overlaid now reports F|PASS. Co-Authored-By: Claude Fable 5.1 --- heart/config/colab_gate.yaml | 8 +++++++- tests/test_colab_gate.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/heart/config/colab_gate.yaml b/heart/config/colab_gate.yaml index e16358f..f03184c 100644 --- a/heart/config/colab_gate.yaml +++ b/heart/config/colab_gate.yaml @@ -17,4 +17,10 @@ # - name: # reason: -accepted_missing: [] +accepted_missing: + - name: colossus + reason: line-of-sight halo tooling in autolens/lens/los.py. colossus is declared only in autogalaxy's test and dev extras, never as a base dependency, and no workspace or HowTo notebook reaches the module. A user who calls the line-of-sight code on Colab gets an explicit ModuleNotFoundError naming it. + - name: hmf + reason: the same autolens/lens/los.py line-of-sight tooling as colossus, and additionally undeclared in any pyproject, so no install path carries it today (tracked as a library follow-up). No workspace or HowTo notebook reaches it. + - name: mcp + reason: autofit/mcp/server.py is the MCP server entry point, installed via the autofit[mcp] extra and launched outside notebooks. An unconditional import is correct for a server module, and no notebook imports it. diff --git a/tests/test_colab_gate.py b/tests/test_colab_gate.py index 0194d7b..32667ce 100644 --- a/tests/test_colab_gate.py +++ b/tests/test_colab_gate.py @@ -585,10 +585,38 @@ def test_detail_line_names_the_first_failures_then_counts_the_rest(): # -------------------------------------------------------------------------- -def test_shipped_config_is_an_empty_accepted_list(): +def test_shipped_config_accepts_exactly_the_three_argued_exemptions(): + """The shipped exemption list, read from the real file the gate loads. + + Three of the five library-side holes the gate found are not notebook + paths: `colossus` and `hmf` (the line-of-sight tooling in + autolens/lens/los.py) and `mcp` (autofit/mcp/server.py, the MCP server + entry point). Anything else appearing here is a hole in the `--no-deps` + bootstrap being quietened rather than fixed, which is what the config + file's own header forbids — so the set is asserted exactly, not as a + subset. + """ config = cg.load_config(cg.CONFIG_PATH) - assert config["accepted_missing"] == [] + assert {entry["name"] for entry in config["accepted_missing"]} == { + "colossus", + "hmf", + "mcp", + } + # A silent exemption is the failure mode the reason field exists to + # prevent: it travels into the JSON report next to the accepted name. + for entry in config["accepted_missing"]: + assert entry.get("reason", "").strip(), entry["name"] + + +def test_shipped_config_reads_identically_without_pyyaml(): + # `verify` runs inside the simulated venv, which need not have PyYAML, so + # the fallback parser is what reads this file in the run that matters. + # Anything the real file uses that only PyYAML understands would silently + # drop an exemption there. + parsed = cg._parse_config_fallback(cg.CONFIG_PATH.read_text()) + + assert parsed == cg.load_config(cg.CONFIG_PATH) def test_config_is_readable_without_pyyaml(tmp_path):