268 lines
10 KiB
Python
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())
|