#!/usr/bin/env python3 """Generate the exact registry package set for a GovOPlaN runtime release.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path import re import subprocess import tomllib META_ROOT = Path(__file__).resolve().parents[2] NAME = re.compile(r"^govoplan-[a-z0-9-]+$") VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$") GIT_REQUIREMENT = re.compile( r"^(?Pgovoplan-[a-z0-9-]+)(?:\[(?P[^]]+)\])?\s+@\s+" r"(?Pgit\+[^\s]+/GovOPlaN/(?Pgovoplan-[a-z0-9-]+)\.git@v" r"(?P[A-Za-z0-9._+!-]+))$" ) LOCAL_CORE = re.compile(r"^(?:-e\s+)?\.\./govoplan-core(?:\[(?P[^]]+)\])?$") EXACT_PACKAGE = re.compile( r"^(?Pgovoplan(?:-[a-z0-9-]+)?)(?:\[(?P[^]]+)\])?==" r"(?P[A-Za-z0-9._+!-]+)$" ) class PackageSetError(ValueError): """Release source references cannot form an immutable package set.""" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--version", help="Core/meta release version. Defaults to the workspace Core version.", ) parser.add_argument( "--requirements", type=Path, default=META_ROOT / "requirements-release.txt", ) parser.add_argument( "--profile", choices=("base", "full"), default="base", help="Base runtime roots or every package selected by govoplan[full].", ) parser.add_argument( "--meta-package", type=Path, default=META_ROOT / "packages" / "govoplan-meta" / "pyproject.toml", ) parser.add_argument("--workspace", type=Path, default=META_ROOT.parent) parser.add_argument("--output", type=Path, required=True) return parser def parse_release_requirements(path: Path, *, core_version: str) -> tuple[dict[str, object], ...]: values: list[dict[str, object]] = [] for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): line = raw.strip() if not line or line.startswith("#"): continue local = LOCAL_CORE.fullmatch(line) if local: values.append( { "name": "govoplan-core", "version": core_version.removeprefix("v"), "repository": "govoplan-core", "extras": _extras(local.group("extras")), } ) continue match = GIT_REQUIREMENT.fullmatch(line) if match is None: raise PackageSetError( f"unsupported release requirement at {path}:{line_number}: {line!r}" ) values.append( { "name": match.group("package"), "version": match.group("version"), "repository": match.group("repo"), "extras": _extras(match.group("extras")), } ) names = [str(item["name"]) for item in values] if not values or names.count("govoplan-core") != 1 or len(names) != len(set(names)): raise PackageSetError("release requirements must contain one Core and unique packages") return tuple(values) def parse_meta_package(path: Path, *, core_version: str) -> tuple[dict[str, object], ...]: project = tomllib.loads(path.read_text(encoding="utf-8")).get("project") if not isinstance(project, dict): raise PackageSetError("developer meta-package has no [project] table") if str(project.get("name") or "") != "govoplan": raise PackageSetError("developer meta-package has an unexpected project name") if str(project.get("version") or "").removeprefix("v") != core_version.removeprefix("v"): raise PackageSetError("developer meta-package version does not match Core") optional = project.get("optional-dependencies") full = optional.get("full") if isinstance(optional, dict) else None dependencies = project.get("dependencies") if not isinstance(dependencies, list) or not isinstance(full, list): raise PackageSetError("developer meta-package must declare dependencies and the full extra") values: list[dict[str, object]] = [] for raw in (*dependencies, *full): if not isinstance(raw, str) or (match := EXACT_PACKAGE.fullmatch(raw.strip())) is None: raise PackageSetError(f"developer meta-package requirement is not exact: {raw!r}") package = match.group("package") repository = "govoplan-core" if package == "govoplan-core" else package values.append( { "name": package, "version": match.group("version"), "repository": repository, "extras": _extras(match.group("extras")), } ) names = [str(item["name"]) for item in values] if names.count("govoplan-core") != 1 or len(names) != len(set(names)): raise PackageSetError("developer meta-package must contain one Core and unique packages") return tuple(values) def generate_package_set( *, core_version: str, requirements: Path, workspace: Path, profile: str = "base", meta_package: Path | None = None, ) -> dict[str, object]: core_version = core_version.removeprefix("v") if VERSION.fullmatch(core_version) is None: raise PackageSetError("release version is invalid") python_packages: list[dict[str, object]] = [] webui_packages: list[dict[str, object]] = [] seen_webui: set[str] = set() if profile not in {"base", "full"}: raise PackageSetError(f"unsupported release profile: {profile}") selected = ( parse_meta_package( meta_package or META_ROOT / "packages" / "govoplan-meta" / "pyproject.toml", core_version=core_version, ) if profile == "full" else parse_release_requirements(requirements, core_version=core_version) ) for requirement in selected: repository = workspace / str(requirement["repository"]) tag = f"v{requirement['version']}" if not (repository / ".git").is_dir(): raise PackageSetError(f"release repository is missing: {repository}") commit = _git(repository, "rev-list", "-n", "1", tag) if not commit: raise PackageSetError(f"release tag is missing: {repository.name}@{tag}") project = tomllib.loads(_git(repository, "show", f"{tag}:pyproject.toml"))["project"] if project.get("name") != requirement["name"] or project.get("version") != requirement["version"]: raise PackageSetError(f"tag metadata does not match {repository.name}@{tag}") entry = { **requirement, "tag": tag, "commit": commit, } python_packages.append(entry) try: webui_raw = _git(repository, "show", f"{tag}:webui/package.json") except subprocess.CalledProcessError: continue webui = json.loads(webui_raw) webui_name = webui.get("name") if ( not isinstance(webui_name, str) or not webui_name.startswith("@govoplan/") or webui.get("version") != requirement["version"] or webui_name in seen_webui ): raise PackageSetError(f"WebUI tag metadata does not match {repository.name}@{tag}") seen_webui.add(webui_name) webui_packages.append( { "name": webui_name, "version": requirement["version"], "repository": requirement["repository"], "tag": tag, "commit": commit, } ) payload: dict[str, object] = { "schema_version": "1", "release_version": core_version, "profile": profile, "registries": { "python": "https://git.add-ideas.de/api/packages/GovOPlaN/pypi/simple", "npm": "https://git.add-ideas.de/api/packages/GovOPlaN/npm/", }, "python": python_packages, "webui": webui_packages, } payload["package_set_sha256"] = _canonical_sha256(payload) return payload def _extras(value: str | None) -> list[str]: if not value: return [] extras = sorted({item.strip() for item in value.split(",") if item.strip()}) if any(re.fullmatch(r"[a-z][a-z0-9_-]*", item) is None for item in extras): raise PackageSetError("release requirement contains an invalid extra") return extras def _git(repository: Path, *arguments: str) -> str: return subprocess.check_output( ["git", "-C", str(repository), *arguments], text=True, stderr=subprocess.DEVNULL, ).strip() def _canonical_sha256(value: object) -> str: encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") return hashlib.sha256(encoded).hexdigest() def main() -> int: args = build_parser().parse_args() try: workspace = args.workspace.expanduser().resolve() version = args.version if not version: core = tomllib.loads( (workspace / "govoplan-core/pyproject.toml").read_text(encoding="utf-8") ) version = str(core["project"]["version"]) payload = generate_package_set( core_version=version, requirements=args.requirements.expanduser().resolve(), workspace=workspace, profile=args.profile, meta_package=args.meta_package.expanduser().resolve(), ) except (PackageSetError, OSError, ValueError, subprocess.CalledProcessError) as exc: print(f"error: {exc}") return 1 output = args.output.expanduser() output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") print(f"Release package set written to {output}") return 0 if __name__ == "__main__": raise SystemExit(main())