"""Local release gate: everything that must hold before publishing a wheel. ``release preflight`` answers one question with an exit code: is this checkout, this version and these wheels safe to publish? It never publishes anything, and by default it makes no network calls. Steps, in order: 1. ``repo-state`` - git repository, clean working tree, recorded commit 2. ``version-consistency`` - __version__ == pyproject dynamic attr == wheel names 3. ``version-strings`` - no stale version literal left in tracked prose 4. ``duplicate-guard`` - already-released version (git tag, optionally PyPI) 5. ``tests`` - pytest (slow/hardware markers excluded) 6. ``bundle-precheck`` - every bundle item resolves; required assets present 7. ``wheel-inspection`` - static metadata/content assertions per wheel 8. ``wheel-runtime`` - one isolated venv per wheel: bundled assets load and real frames flow through the public pipeline Output lands in ``build/release/reports//preflight.{json,txt}``. """ from __future__ import annotations import hashlib import json import platform import subprocess import sys import time from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path _BUILD_ROOT = Path(__file__).resolve().parents[1] if str(_BUILD_ROOT) not in sys.path: sys.path.insert(0, str(_BUILD_ROOT)) from common.paths import REPO_ROOT # noqa: E402 from common.pyproject_utils import bundle_items, get_version # noqa: E402 from common.verify.wheel import ( # noqa: E402 DEFAULT_RUNTIME_CONFIG, default_verification_video, verify_wheel, ) from common.verify.wheel_contents import inspect_wheel # noqa: E402 from release import report as report_mod # noqa: E402 from release import version as version_mod # noqa: E402 from wheel.layout import resolve_bundle_source_files, verify_required_assets # noqa: E402 @dataclass class Step: name: str ok: bool detail: str = "" duration_s: float = 0.0 data: dict = field(default_factory=dict) @dataclass class PreflightOptions: version: str | None = None wheel_dir: Path | None = None video: Path | None = None config_name: str = DEFAULT_RUNTIME_CONFIG frames: int = 40 skip_tests: bool = False longrun: bool = False allow_dirty: bool = False force: bool = False check_pypi: bool = False all_wheel_pythons: bool = False offline: bool = False keep_going: bool = False report_dir: Path | None = None no_runtime: bool = False no_assets: bool = False def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with Path(path).open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") class Preflight: def __init__(self, options: PreflightOptions, repo_root: Path | None = None) -> None: self.options = options self.repo_root = Path(repo_root or REPO_ROOT) self.steps: list[Step] = [] self.git: dict = {"commit": None, "branch": None, "dirty": None} self.wheels: list[Path] = [] self.wheel_dir: Path | None = None self.runtime_records: list[dict] = [] self.aborted = False self.ok = False if options.version: self.version = options.version self.version_source = "explicit --version" elif (self.repo_root / "pyproject.toml").is_file(): self.version = get_version(self.repo_root) self.version_source = "pyproject.toml dynamic attr" else: # Not version-shaped on purpose: this is a sentinel for "unresolved", # and it must not look like a release version to the version-string scan. self.version = "unknown" self.version_source = "unresolved" self.report_dir = report_mod.reports_root(self.repo_root, options.report_dir) / self.version # -- helpers --------------------------------------------------------- def _git(self, *args: str) -> str: try: completed = subprocess.run( ["git", *args], cwd=self.repo_root, capture_output=True, text=True, check=False, ) except OSError: return "" return (completed.stdout or "").strip() def _execute(self, step_fn) -> None: started = time.perf_counter() try: step = step_fn() except Exception as exc: # a crashed step is a failed step, never a silent pass step = Step(name=getattr(step_fn, "__name__", "step").lstrip("_").replace("_", "-"), ok=False, detail=f"crashed: {type(exc).__name__}: {exc}") step.duration_s = round(time.perf_counter() - started, 3) self.steps.append(step) flag = "ok " if step.ok else "FAIL" print(f"[{flag}] {step.name} ({step.duration_s:.2f}s)") for line in str(step.detail).splitlines(): print(f" {line}") if not step.ok and not self.options.keep_going: self.aborted = True print(" stopping: pass --keep-going to collect every failure") # -- steps ----------------------------------------------------------- def _repo_state(self) -> Step: if not (self.repo_root / ".git").exists(): return Step("repo-state", False, f"{self.repo_root} is not a git repository") commit = self._git("rev-parse", "HEAD") branch = self._git("rev-parse", "--abbrev-ref", "HEAD") porcelain = self._git("status", "--porcelain") dirty = bool(porcelain) self.git = {"commit": commit, "branch": branch, "dirty": dirty} if dirty and not self.options.allow_dirty: preview = "\n".join(porcelain.splitlines()[:20]) return Step( "repo-state", False, f"working tree is dirty; commit, stash or pass --allow-dirty:\n{preview}", data=self.git, ) detail = f"{branch} @ {commit[:12]} dirty={dirty} version={self.version} ({self.version_source})" if dirty: detail += " (accepted via --allow-dirty)" return Step("repo-state", True, detail, data=self.git) def _version_consistency(self) -> Step: problems: list[str] = [] data: dict = {"version": self.version, "source": self.version_source} attr = version_mod.dynamic_version_attr(self.repo_root) data["dynamic_attr"] = attr if attr is None: problems.append( "pyproject.toml no longer declares tool.setuptools.dynamic.version.attr" ) if (self.repo_root / "pyproject.toml").is_file(): resolved = get_version(self.repo_root) data["resolved_version"] = resolved if resolved != self.version: problems.append( f"src/orisys/__init__.py __version__ is {resolved!r} but the release " f"version is {self.version!r}" ) sdk_dirs = version_mod.sdk_versions(self.repo_root) data["sdk_versions"] = sdk_dirs for wheel in version_mod.sdk_wheels(self.repo_root, self.version): found = version_mod.wheel_version(wheel) if found != self.version: problems.append(f"{wheel.name} carries version {found!r}, expected {self.version!r}") detail = ( f"version={self.version} ({self.version_source}) dynamic_attr={attr}\n" f"SDK/ versions present: {sdk_dirs or 'none'}" ) if problems: detail += "\n" + "\n".join(problems) return Step("version-consistency", not problems, detail, data=data) def _duplicate_guard(self) -> Step: """Fail only when this version has already been *released*. A populated ``SDK//`` is the normal input to preflight (it is what gets verified), so it is reported, not blocked. What must not happen silently is re-releasing a version that is already out: that is detected offline through git tags and, opt-in, against PyPI. """ problems: list[str] = [] lines: list[str] = [] existing = version_mod.sdk_wheels(self.repo_root, self.version) if existing: lines.append( f"SDK/{self.version}/ holds {len(existing)} staged wheel(s) to verify: " f"{[w.name for w in existing]}" ) else: lines.append( f"SDK/{self.version}/ is empty; nothing is staged yet " "(build with `python build/run.py wheel-win` / `manylinux`)" ) tags = version_mod.git_tags(self.repo_root) tag_hits = version_mod.released_tags_for(self.version, tags) if tag_hits: message = f"git tag(s) {tag_hits} already mark {self.version} as released" if self.options.force: lines.append(message + " (accepted via --force)") else: problems.append( message + "; pass --force to re-verify or intentionally re-release" ) else: lines.append( f"no local git tag marks {self.version} as released " f"({len(tags)} tag(s) checked in this repo and {version_mod.RELEASE_REPO_DIRNAME})" ) if self.options.check_pypi: versions, error = version_mod.pypi_released_versions() if error: problems.append(f"PyPI lookup failed: {error}") elif versions is None: problems.append("PyPI lookup returned no version list") elif self.version in versions: problems.append( f"orisys {self.version} is already published on PyPI; a PyPI upload " "cannot be undone. Published: " + ", ".join(versions) ) else: lines.append( f"PyPI has no {self.version}; published: " + (", ".join(versions) or "none") ) else: lines.append("PyPI existence check skipped (pass --check-pypi to enable)") detail = "\n".join(lines) if problems: detail += "\n" + "\n".join(problems) return Step("duplicate-guard", not problems, detail) def _version_strings(self) -> Step: """Keep the version out of prose: one source, everything else derived. Scans tracked text for version-looking literals that disagree with the release version. Dependency constraints are ignored by design; deliberate exceptions live in ``version.VERSION_STRING_ALLOWLIST``. """ findings = version_mod.find_version_literals(self.repo_root, self.version) if not findings: scanned = len(version_mod.tracked_text_files(self.repo_root)) return Step( "version-strings", True, f"no stale version literal in {scanned} scanned file(s); " f"version {self.version} lives only where it is derived from", ) by_file: dict[str, list[str]] = {} for path, lineno, literal in findings: by_file.setdefault(path, []).append(f"{path}:{lineno} -> {literal}") detail = ( f"{len(findings)} version literal(s) disagree with {self.version}. " "Update them, or make the file version-agnostic, or add a documented " "entry to build/release/version.py::VERSION_STRING_ALLOWLIST:\n" + "\n".join(line for lines in by_file.values() for line in lines) ) return Step("version-strings", False, detail, data={"count": len(findings)}) def _tests(self) -> Step: if self.options.skip_tests: return Step( "tests", True, "WARNING: test gate SKIPPED via --skip-tests; this run cannot certify a release", data={"status": "skipped"}, ) marker = "not hardware" if self.options.longrun else "not slow and not hardware" self.report_dir.mkdir(parents=True, exist_ok=True) junit = self.report_dir / "junit.xml" command = [ sys.executable, "-m", "pytest", "-q", "-m", marker, f"--junitxml={junit}", ] try: completed = subprocess.run( command, cwd=self.repo_root, capture_output=True, text=True, check=False ) except FileNotFoundError as exc: return Step("tests", False, f"cannot run pytest: {exc}") ok = completed.returncode == 0 tail = ((completed.stdout or "") + (completed.stderr or "")).strip().splitlines()[-12:] detail = f"pytest -m {marker!r} in {sys.executable}\nexit code {completed.returncode}" if tail: detail += "\n" + "\n".join(tail) return Step("tests", ok, detail, data={"marker": marker, "junit": str(junit)}) def _bundle_precheck(self) -> Step: if not (self.repo_root / "pyproject.toml").is_file(): return Step("bundle-precheck", False, "pyproject.toml not found") items = bundle_items(self.repo_root) problems: list[str] = [] counts: dict[str, int] = {} for item in items: files = resolve_bundle_source_files(self.repo_root, item) counts[str(item.get("name"))] = len(files) if not files: problems.append( f"bundle item {item.get('name')!r} resolved 0 files; its patterns match nothing" ) try: verify_required_assets(self.repo_root, items) except FileNotFoundError as exc: problems.append(str(exc)) detail = "resolved files per bundle item: " + json.dumps(counts) if problems: detail += "\n" + "\n".join(problems) return Step("bundle-precheck", not problems, detail, data={"counts": counts}) def _discover_wheels(self) -> str | None: """Populate self.wheels; return an error message when nothing was found.""" directory = ( Path(self.options.wheel_dir) if self.options.wheel_dir else self.repo_root / "SDK" / self.version ) self.wheel_dir = directory if not directory.is_dir(): return ( f"{directory} does not exist. Build first " "(`python build/run.py wheel`) or pass --wheel-dir." ) self.wheels = sorted(directory.glob("*.whl")) if not self.wheels: return f"no *.whl found in {directory}. Build first or pass --wheel-dir." return None def _wheel_inspection(self) -> Step: problem = self._discover_wheels() if problem: return Step("wheel-inspection", False, problem) problems: list[str] = [] blocks: list[str] = [] for wheel in self.wheels: result = inspect_wheel( wheel, expected_version=self.version, repo_root=self.repo_root, strict_bundle=True, ) blocks.append(result.render()) if not result.ok: problems.append( f"{wheel.name}: " + ", ".join(check.name for check in result.failures) ) detail = f"{len(self.wheels)} wheel(s) in {self.wheel_dir}\n" + "\n".join(blocks) if problems: detail += "\nfailed checks:\n" + "\n".join(problems) return Step("wheel-inspection", not problems, detail, data={"count": len(self.wheels)}) def _platform_token(self) -> str: return "win_amd64" if sys.platform == "win32" else "manylinux" def _runtime_targets(self) -> list[tuple[str, Path, Path | None]]: token = self._platform_token() if self.options.all_wheel_pythons: targets: list[tuple[str, Path, Path | None]] = [] for tag in ("cp310", "cp311", "cp312"): match = next( (w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None ) if match is None: raise RuntimeError( f"no {tag} {token} wheel among {[w.name for w in self.wheels]}" ) executable = version_mod.python_for_tag(tag) if executable is None: raise RuntimeError( f"cannot resolve an interpreter for {tag}; create the conda env " f"py{tag[2:]} or set CONDA_ENVS_ROOT" ) targets.append((tag, match, executable)) return targets tag = f"cp{sys.version_info.major}{sys.version_info.minor}" match = next((w for w in self.wheels if f"-{tag}-" in w.name and token in w.name), None) if match is None: raise RuntimeError( f"no {tag} {token} wheel among {[w.name for w in self.wheels]}; pass " "--all-wheel-pythons to verify every ABI in its own environment, or run from " "a matching interpreter" ) return [(tag, match, None)] def _wheel_runtime(self) -> Step: if self.options.no_runtime: return Step( "wheel-runtime", True, "WARNING: runtime verification SKIPPED via --no-runtime", data={"status": "skipped"}, ) video = self.options.video or default_verification_video(self.repo_root) if video is None: return Step( "wheel-runtime", False, "no verification clip found. Pass --video, set ORISYS_VERIFY_VIDEO, or commit a " "clip under data/sample/. The runtime check is never silently skipped.", ) video = Path(video) if not video.is_file(): return Step("wheel-runtime", False, f"verification clip not found: {video}") try: targets = self._runtime_targets() except RuntimeError as exc: return Step("wheel-runtime", False, str(exc)) problems: list[str] = [] lines: list[str] = [f"clip: {video}", f"config: {self.options.config_name}"] for tag, wheel, executable in targets: label = f"{tag} {wheel.name}" try: # One venv per wheel: it runs the import smoke test, the bundled # asset check and the frame pipeline together. record = verify_wheel( wheel, video=video, config_name=self.options.config_name, frames=self.options.frames, python_executable=executable, offline=self.options.offline, check_assets=not self.options.no_assets, ) except subprocess.CalledProcessError as exc: problems.append(f"{label}: command failed ({exc})") continue except Exception as exc: problems.append(f"{label}: {type(exc).__name__}: {exc}") continue lines.append( f"{label}: {record['assets']} | frames={record['frames']} " f"flow={record['flow_shape']}" ) self.runtime_records.append({"tag": tag, **record}) detail = "\n".join(lines) if problems: detail += "\nfailures:\n" + "\n".join(problems) return Step("wheel-runtime", not problems, detail, data={"records": self.runtime_records}) # -- driver ---------------------------------------------------------- def run(self) -> int: started_at = _now() started = time.perf_counter() print(f"=== orisys release preflight: version {self.version} ({self.version_source}) ===") print(f"repo: {self.repo_root}") print(f"report: {self.report_dir}") print() try: for step_fn in ( self._repo_state, self._version_consistency, self._version_strings, self._duplicate_guard, self._tests, self._bundle_precheck, self._wheel_inspection, self._wheel_runtime, ): self._execute(step_fn) if self.aborted: break finally: self.ok = bool(self.steps) and all(step.ok for step in self.steps) and not self.aborted duration = round(time.perf_counter() - started, 3) payload = { "version": self.version, "version_source": self.version_source, "ok": self.ok, "aborted": self.aborted, "started_at": started_at, "finished_at": _now(), "duration_s": duration, "git": self.git, "interpreter": { "executable": sys.executable, "version": platform.python_version(), "platform": sys.platform, }, "options": {key: value for key, value in asdict(self.options).items() if not isinstance(value, Path)}, "steps": [asdict(step) for step in self.steps], "wheels": [ { "name": wheel.name, "size": wheel.stat().st_size, "sha256": _sha256_file(wheel), } for wheel in self.wheels ], } payload["options"]["wheel_dir"] = str(self.options.wheel_dir) if self.options.wheel_dir else None payload["options"]["video"] = str(self.options.video) if self.options.video else None json_path, text_path = report_mod.write_report(payload, self.report_dir) print() print(f"report: {json_path}") print(f"report: {text_path}") print() print(f"=== preflight {'PASSED' if self.ok else 'FAILED'} ({duration}s) ===") return 0 if self.ok else 1 def run_preflight(options: PreflightOptions, repo_root: Path | None = None) -> int: return Preflight(options, repo_root=repo_root).run()