Files
govoplan/tools/release/generate-release-package-set.py
T

268 lines
10 KiB
Python

#!/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 sys
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"^(?P<package>govoplan-[a-z0-9-]+)(?:\[(?P<extras>[^]]+)\])?\s+@\s+"
r"(?P<url>git\+[^\s]+/GovOPlaN/(?P<repo>govoplan-[a-z0-9-]+)\.git@v"
r"(?P<version>[A-Za-z0-9._+!-]+))$"
)
LOCAL_CORE = re.compile(r"^(?:-e\s+)?\.\./govoplan-core(?:\[(?P<extras>[^]]+)\])?$")
EXACT_PACKAGE = re.compile(
r"^(?P<package>govoplan(?:-[a-z0-9-]+)?)(?:\[(?P<extras>[^]]+)\])?=="
r"(?P<version>[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:
release_root = str(Path(__file__).resolve().parent)
if release_root not in sys.path:
sys.path.insert(0, release_root)
from govoplan_release.git_state import sanitized_git_environment, scoped_git_command
return subprocess.check_output(
scoped_git_command(repository, "-C", str(repository), *arguments),
text=True,
stderr=subprocess.DEVNULL,
env=sanitized_git_environment(),
timeout=30,
).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())