From 6300ebdb47e92058648bdc31c65a6889a2c731ea Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:13:03 +0200 Subject: [PATCH 1/2] api: prove source and wheel public parity --- scripts/prove_public_api_parity.py | 338 ++++++++++++++++++ .../test_public_api_parity_proof.py | 77 ++++ 2 files changed, 415 insertions(+) create mode 100644 scripts/prove_public_api_parity.py create mode 100644 tests/python/architecture/test_public_api_parity_proof.py diff --git a/scripts/prove_public_api_parity.py b/scripts/prove_public_api_parity.py new file mode 100644 index 000000000..013806506 --- /dev/null +++ b/scripts/prove_public_api_parity.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Prove that the release wheel and source checkout expose one pure-Python API.""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping, Sequence +import hashlib +import json +from pathlib import Path, PurePosixPath +import subprocess +import sys +import tempfile +from typing import Any +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_PACKAGE = ROOT / "python" / "pops" +PROOF_SCHEMA_VERSION = 1 +TYPED_PAYLOAD_SUFFIXES = (".py", ".pyi") +PUBLIC_ROOT = ( + "Model", + "Program", + "Case", + "RunReport", + "RunStopReason", + "ExecutionContext", + "set_threads", + "validate", + "inspect", + "explain", + "resolve", + "compile", + "bind", + "run", + "__version__", +) + +_SNAPSHOT_PROGRAM = r""" +import hashlib +import inspect as _inspect +import json +from pathlib import Path +import sys + +package_parent = Path(sys.argv[1]).resolve() +sys.path.insert(0, str(package_parent)) +import pops + +expected_retired = ( + "Problem", + "RuntimePolicies", + "OutputPolicy", + "CheckpointPolicy", + "System", + "AmrSystem", + "ModelSpec", + "BindInputs", + "SystemConfig", + "AmrSystemConfig", + "CompiledTime", + "compile_library", + "read_library_manifest", + "LibraryManifest", +) +expected_public = ( + "Model", + "Program", + "Case", + "RunReport", + "RunStopReason", + "ExecutionContext", + "set_threads", + "validate", + "inspect", + "explain", + "resolve", + "compile", + "bind", + "run", + "__version__", +) +if tuple(pops.__all__) != expected_public: + raise RuntimeError("root public API does not match the final contract") +if "pops._pops" in sys.modules: + raise RuntimeError("root import loaded pops._pops") +if not isinstance(pops.Case, type) or "__getattr__" in pops.Case.__dict__: + raise RuntimeError("Case is not one explicit public type") +if "__getattr__" in pops.__dict__: + raise RuntimeError("root package uses a dynamic public facade") +if any(hasattr(pops, name) for name in expected_retired): + raise RuntimeError("root package still exposes a replaced public name") +if not (Path(pops.__file__).resolve().parent / "py.typed").is_file(): + raise RuntimeError("package has no py.typed marker") + +model = pops.Model("parity") +state = model.state("U", components=("u",)) +case = pops.Case("two_instances") +left = case.block("left", model) +right = case.block("right", model) +left_state = case.qualify(state, block=left) +right_state = case.qualify(state, block=right) +if left_state == right_state or left_state.block_ref != left or right_state.block_ref != right: + raise RuntimeError("qualified handles do not disambiguate repeated Model instances") +if pops.validate(case) is not case or not case.frozen: + raise RuntimeError("pure-Python validation did not freeze the exact Case") +report = pops.inspect(case) +if report["name"] != "two_instances" or set(report["blocks"]) != {"left", "right"}: + raise RuntimeError("pure-Python inspection did not preserve qualified blocks") +if "pops._pops" in sys.modules: + raise RuntimeError("authoring, validation, or inspection loaded pops._pops") + +def _annotation(value): + if isinstance(value, str): + return value + module = getattr(value, "__module__", None) + qualname = getattr(value, "__qualname__", None) + if module and qualname: + return module + "." + qualname + return repr(value) + +def _symbol(name): + value = getattr(pops, name) + if _inspect.isclass(value): + kind = "class" + elif _inspect.isfunction(value): + kind = "function" + else: + kind = type(value).__name__ + try: + call_signature = str(_inspect.signature(value, eval_str=False)) + except (TypeError, ValueError): + call_signature = None + annotations = getattr(value, "__annotations__", {}) + return { + "kind": kind, + "module": getattr(value, "__module__", None), + "qualname": getattr(value, "__qualname__", None), + "signature": call_signature, + "annotations": { + key: _annotation(annotation) + for key, annotation in sorted(annotations.items()) + }, + } + +public = list(pops.__all__) +snapshot = { + "public": public, + "symbols": {name: _symbol(name) for name in public}, + "case_is_explicit_type": True, + "qualified_handles": True, + "pure_authoring": True, + "py_typed": True, +} +print(json.dumps(snapshot, sort_keys=True, separators=(",", ":"))) +""" + + +class PublicApiParityError(RuntimeError): + """The source checkout and release wheel do not expose one exact public API.""" + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _sha256(path: Path) -> str: + return _sha256_bytes(path.read_bytes()) + + +def _is_typed_payload(relative: str) -> bool: + path = PurePosixPath(relative) + return path.name == "py.typed" or path.suffix in TYPED_PAYLOAD_SUFFIXES + + +def _source_manifest(package: Path = SOURCE_PACKAGE) -> dict[str, str]: + if not package.is_dir(): + raise PublicApiParityError("source package is absent: %s" % package) + manifest = { + path.relative_to(package).as_posix(): _sha256(path) + for path in sorted(package.rglob("*")) + if path.is_file() + and "__pycache__" not in path.parts + and _is_typed_payload(path.relative_to(package).as_posix()) + } + required = {"__init__.py", "_pops.pyi", "py.typed"} + if not required.issubset(manifest): + raise PublicApiParityError("source package lacks its root API or typing payload") + return manifest + + +def _wheel_manifest(archive: zipfile.ZipFile) -> dict[str, str]: + members = [ + info + for info in archive.infolist() + if not info.is_dir() and info.filename.startswith("pops/") + ] + names = [info.filename for info in members] + if len(names) != len(set(names)): + raise PublicApiParityError("release wheel contains duplicate pops package members") + manifest = { + info.filename.removeprefix("pops/"): _sha256_bytes(archive.read(info)) + for info in members + if _is_typed_payload(info.filename.removeprefix("pops/")) + } + required = {"__init__.py", "_pops.pyi", "py.typed"} + if not required.issubset(manifest): + raise PublicApiParityError("release wheel lacks its root API or typing payload") + return manifest + + +def _safe_extract(archive: zipfile.ZipFile, destination: Path) -> None: + for info in archive.infolist(): + relative = PurePosixPath(info.filename) + if relative.is_absolute() or ".." in relative.parts: + raise PublicApiParityError("release wheel contains an unsafe member path") + archive.extractall(destination) + + +def _snapshot(package_parent: Path) -> dict[str, Any]: + completed = subprocess.run( + [sys.executable, "-I", "-c", _SNAPSHOT_PROGRAM, str(package_parent.resolve())], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + env={"PYTHONDONTWRITEBYTECODE": "1"}, + ) + if completed.returncode: + raise PublicApiParityError( + "public API snapshot failed for %s:\n%s" + % (package_parent, completed.stdout[-4000:]) + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise PublicApiParityError( + "public API snapshot was not JSON for %s" % package_parent + ) from exc + if not isinstance(payload, dict): + raise PublicApiParityError("public API snapshot is not an object") + return payload + + +def _canonical_sha256(payload: Mapping[str, Any]) -> str: + encoded = json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode("utf-8") + return _sha256_bytes(encoded) + + +def build_proof(wheel: Path) -> dict[str, Any]: + """Compare one exact wheel archive with the current source checkout.""" + retained = wheel.expanduser().resolve() + if retained.suffix != ".whl" or not retained.is_file(): + raise PublicApiParityError("release artifact is not one readable wheel") + source_manifest = _source_manifest() + try: + with tempfile.TemporaryDirectory(prefix="pops-public-api-") as temporary: + extracted = Path(temporary) + with zipfile.ZipFile(retained) as archive: + wheel_manifest = _wheel_manifest(archive) + if wheel_manifest != source_manifest: + missing = sorted(set(source_manifest) - set(wheel_manifest)) + extra = sorted(set(wheel_manifest) - set(source_manifest)) + changed = sorted( + name + for name in set(source_manifest) & set(wheel_manifest) + if source_manifest[name] != wheel_manifest[name] + ) + raise PublicApiParityError( + "wheel Python/typing payload differs from source " + "(missing=%s, extra=%s, changed=%s)" + % (missing[:8], extra[:8], changed[:8]) + ) + _safe_extract(archive, extracted) + source_snapshot = _snapshot(SOURCE_PACKAGE.parent) + wheel_snapshot = _snapshot(extracted) + except (OSError, zipfile.BadZipFile) as exc: + raise PublicApiParityError("release wheel is unreadable: %s" % exc) from exc + if wheel_snapshot != source_snapshot: + raise PublicApiParityError("wheel and source public API snapshots differ") + if tuple(source_snapshot["public"]) != PUBLIC_ROOT: + raise PublicApiParityError("public API snapshot differs from the final root contract") + return { + "schema_version": PROOF_SCHEMA_VERSION, + "wheel_path": str(retained), + "wheel_sha256": _sha256(retained), + "typed_payload_files": len(source_manifest), + "typed_payload_sha256": _canonical_sha256(source_manifest), + "public_api_sha256": _canonical_sha256(source_snapshot), + "public_names": source_snapshot["public"], + "pure_authoring": source_snapshot["pure_authoring"], + "qualified_handles": source_snapshot["qualified_handles"], + "py_typed": source_snapshot["py_typed"], + } + + +def _write_evidence(path: Path, proof: Mapping[str, Any]) -> None: + destination = path.expanduser().resolve() + try: + destination.relative_to(ROOT) + except ValueError: + pass + else: + raise PublicApiParityError("evidence path must be outside the checkout") + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + raise PublicApiParityError("refusing to overwrite public API evidence: %s" % destination) + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=destination.parent, delete=False + ) as stream: + json.dump(proof, stream, sort_keys=True, indent=2) + stream.write("\n") + temporary = Path(stream.name) + temporary.replace(destination) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--wheel", required=True, type=Path) + parser.add_argument("--evidence", type=Path) + args = parser.parse_args(argv) + try: + proof = build_proof(args.wheel) + if args.evidence is not None: + _write_evidence(args.evidence, proof) + except (PublicApiParityError, OSError, ValueError) as exc: + print("public API parity proof failed: %s" % exc, file=sys.stderr) + return 1 + print(json.dumps(proof, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/python/architecture/test_public_api_parity_proof.py b/tests/python/architecture/test_public_api_parity_proof.py new file mode 100644 index 000000000..53af6f9c8 --- /dev/null +++ b/tests/python/architecture/test_public_api_parity_proof.py @@ -0,0 +1,77 @@ +"""ADC-689 source/wheel public API and typing parity proof.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import zipfile + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "prove_public_api_parity.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("_public_api_parity_test", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +proof = _load() + + +def _synthetic_wheel(path: Path, *, omit: str | None = None) -> None: + with zipfile.ZipFile(path, "w") as archive: + for source in sorted(proof.SOURCE_PACKAGE.rglob("*")): + if not source.is_file() or "__pycache__" in source.parts: + continue + relative = source.relative_to(proof.SOURCE_PACKAGE).as_posix() + if relative == omit: + continue + archive.write(source, "pops/" + relative) + archive.writestr( + "pops-1.0.0.dist-info/METADATA", + "Metadata-Version: 2.3\nName: PoPS\nVersion: 1.0.0\n", + ) + + +def test_exact_wheel_and_source_share_public_api_typing_and_lazy_authoring(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel) + + evidence = proof.build_proof(wheel) + + assert evidence["schema_version"] == 1 + assert evidence["public_names"] == list(proof.PUBLIC_ROOT) + assert evidence["pure_authoring"] is True + assert evidence["qualified_handles"] is True + assert evidence["py_typed"] is True + assert evidence["typed_payload_files"] > 100 + + +def test_wheel_proof_fails_closed_when_typing_payload_is_missing(tmp_path): + wheel = tmp_path / "pops-1.0.0-py3-none-any.whl" + _synthetic_wheel(wheel, omit="_pops.pyi") + + with pytest.raises(proof.PublicApiParityError, match="typing payload"): + proof.build_proof(wheel) + + +def test_release_workflow_blocks_publication_on_source_wheel_api_parity(): + workflow = (ROOT / ".github" / "workflows" / "release.yml").read_text( + encoding="utf-8" + ) + validate = workflow[workflow.index(" validate:") : workflow.index(" release:")] + + assert "scripts/prove_public_api_parity.py" in validate + assert '--wheel "${wheels[0]}"' in validate + assert 'pops-final-evidence-public-api.json' in validate + assert validate.index("scripts/prove_public_api_parity.py") < validate.index( + "scripts/run_final_gate.py" + ) From 23d9aca752894e7ab1f62f86975df4d5ea7d348e Mon Sep 17 00:00:00 2001 From: desp0042 Date: Thu, 30 Jul 2026 06:13:19 +0200 Subject: [PATCH 2/2] release: gate publication on public API parity --- .github/workflows/release.yml | 3 +++ .../SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md | 9 +++++++++ docs/docmap.toml | 3 +++ 3 files changed, 15 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c2714279..5ebbba136 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,6 +61,9 @@ jobs: wheels=("$RUNNER_TEMP"/wheelhouse/pops-*.whl) test "${#wheels[@]}" -eq 1 evidence="$RUNNER_TEMP/pops-final-evidence.json" + python scripts/prove_public_api_parity.py \ + --wheel "${wheels[0]}" \ + --evidence "$RUNNER_TEMP/pops-final-evidence-public-api.json" python scripts/run_final_gate.py --wheel "${wheels[0]}" --evidence "$evidence" python - <<'PY' from pops.runtime_environment import runtime_environment_report diff --git a/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md b/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md index e8bb1baf1..5633c7c83 100644 --- a/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md +++ b/docs/design/SPECIFICATION_TECHNIQUE_FINALE_POPS_ARCHITECTURE.md @@ -1536,6 +1536,15 @@ dans `examples/final/`. Chaque script doit : ## 14. Gate de conformance finale +Le job de release commence par +`scripts/prove_public_api_parity.py --wheel --evidence `. +Cette preuve compare octet par octet tous les fichiers Python et de typage (`*.py`, `*.pyi`, +`py.typed`) du checkout et du wheel retenu, puis importe séparément les deux arbres dans des +interpréteurs isolés. Les deux snapshots doivent exposer la même racine publique, les mêmes +signatures et annotations, un `Case` explicite, des handles qualifiés distincts et +authoring/validation/inspection sans chargement de `_pops`. Un ancien nom public, un fichier de +typage absent ou une divergence source/wheel bloque la publication. + Une release ne peut être déclarée conforme que par `scripts/run_final_gate.py --evidence `. La commande exige un checkout propre, refuse d'écraser une evidence existante et produit une evidence JSON liée au commit, à la version du diff --git a/docs/docmap.toml b/docs/docmap.toml index 812be7ecc..c5663ad22 100644 --- a/docs/docmap.toml +++ b/docs/docmap.toml @@ -84,6 +84,8 @@ depends_on = [ "python/pops/physics/board.py", "python/pops/problem/problem.py", "python/pops/time/_program/api.py", + "scripts/prove_public_api_parity.py", + ".github/workflows/release.yml", "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_SCALAIRE_COMPLET.py", "examples/final/EXEMPLE_SPEC_FINALE_MULTIPHYSIQUE_CORE.py", "examples/final/EXEMPLE_SPEC_FINALE_ADVECTION_IMEX_AMR.py", @@ -91,6 +93,7 @@ depends_on = [ ] tested_by = [ "tests/python/architecture/test_final_public_api.py", + "tests/python/architecture/test_public_api_parity_proof.py", "tests/python/architecture/test_release_contract.py", ] testable = true