"""Synthesize initial catalog entries from a repository's package and manifest.""" from __future__ import annotations import argparse from contextlib import contextmanager import importlib import json import os from pathlib import Path import subprocess import sys import tomllib from types import ModuleType from typing import Iterator from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.versioning import version_satisfies_range from .workspace import load_repository_specs, resolve_repo_path _INSPECTION_CHILD = "GOVOPLAN_CATALOG_ENTRY_INSPECTION_CHILD" _INSPECTION_MARKER = "GOVOPLAN_CATALOG_ENTRY_JSON=" def synthesize_repository_catalog_entries( *, repo: str, version: str, workspace: Path, repository_base: str, ) -> tuple[dict[str, object], ...]: """Build install entries from tagged, preflighted local source metadata. The caller owns source-tag and worktree provenance checks. This function accepts no hand-maintained module catalog registry: distribution metadata identifies the runtime entry point and the runtime ``ModuleManifest`` is the authoritative dependency/interface/frontend description. """ if os.getenv(_INSPECTION_CHILD) == "1": return synthesize_repository_catalog_entries_in_process( repo=repo, version=version, workspace=workspace, repository_base=repository_base, ) command = ( sys.executable, "-m", "govoplan_release.catalog_entry_synthesis", "--inspect-repo", repo, "--version", version, "--workspace", str(workspace), "--repository-base", repository_base, ) environment = os.environ.copy() environment[_INSPECTION_CHILD] = "1" release_root = str(Path(__file__).resolve().parents[1]) environment["PYTHONPATH"] = os.pathsep.join( item for item in (release_root, environment.get("PYTHONPATH", "")) if item ) try: result = subprocess.run( command, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30, env=environment, ) except subprocess.TimeoutExpired as exc: raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection timed out.") from exc if result.returncode != 0: detail = result.stderr.strip() or result.stdout.strip() or "unknown inspection error" raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection failed: {detail}") payload_line = next( (line.removeprefix(_INSPECTION_MARKER) for line in reversed(result.stdout.splitlines()) if line.startswith(_INSPECTION_MARKER)), None, ) if payload_line is None: raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection returned no metadata.") payload = json.loads(payload_line) if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): raise ValueError(f"Cannot synthesize {repo}: isolated manifest inspection returned invalid metadata.") return tuple(payload) def synthesize_repository_catalog_entries_in_process( *, repo: str, version: str, workspace: Path, repository_base: str, ) -> tuple[dict[str, object], ...]: specs = {item.name: item for item in load_repository_specs(include_website=False)} spec = specs.get(repo) if spec is None: raise ValueError(f"Cannot synthesize {repo}: repository is not registered.") root = resolve_repo_path(spec, workspace) project = read_project_metadata(root / "pyproject.toml") package = required_text(project, "name", source=f"{repo}/pyproject.toml") project_version = required_text(project, "version", source=f"{repo}/pyproject.toml").removeprefix("v") expected_version = version.removeprefix("v") if project_version != expected_version: raise ValueError( f"Cannot synthesize {repo}: project version {project_version!r} does not match selected version {expected_version!r}." ) description = optional_text(project.get("description")) entry_points = module_entry_points(project, repo=repo) entries: list[dict[str, object]] = [] for declared_module_id, target in sorted(entry_points.items()): manifest = load_manifest(root=root, target=target, repo=repo) if manifest.id != declared_module_id: raise ValueError( f"Cannot synthesize {repo}: entry point {declared_module_id!r} returns manifest {manifest.id!r}." ) if manifest.version.removeprefix("v") != expected_version: raise ValueError( f"Cannot synthesize {repo}/{manifest.id}: manifest version {manifest.version!r} does not match {expected_version!r}." ) entry = manifest_catalog_entry( manifest=manifest, repo=repo, package=package, version=expected_version, description=description, root=root, repository_base=repository_base.rstrip("/"), ) entries.append(entry) return tuple(entries) def manifest_catalog_entry( *, manifest: ModuleManifest, repo: str, package: str, version: str, description: str | None, root: Path, repository_base: str, ) -> dict[str, object]: tag = f"v{version}" entry: dict[str, object] = { "module_id": manifest.id, "name": manifest.name, "version": version, "action": "install", "python_package": package, "python_ref": f"{package} @ {repository_base}/{repo}.git@{tag}", "license_features": [f"module.{manifest.id}"], "tags": ["official"], } if description: entry["description"] = description if manifest.dependencies: entry["dependencies"] = list(manifest.dependencies) if manifest.optional_dependencies: entry["optional_dependencies"] = list(manifest.optional_dependencies) if manifest.provides_interfaces: entry["provides_interfaces"] = [ {"name": item.name, "version": item.version} for item in manifest.provides_interfaces ] if manifest.requires_interfaces: entry["requires_interfaces"] = [ { "name": item.name, **({"version_min": item.version_min} if item.version_min else {}), **({"version_max_exclusive": item.version_max_exclusive} if item.version_max_exclusive else {}), "optional": item.optional, } for item in manifest.requires_interfaces ] if manifest.frontend is not None and manifest.frontend.package_name: validate_webui_package(root=root, package=manifest.frontend.package_name, version=version, repo=repo) entry["webui_package"] = manifest.frontend.package_name entry["webui_ref"] = f"{repository_base}/{repo}.git#{tag}" if manifest.migration_spec is not None: migration = manifest.migration_spec entry["migration_safety"] = "requires_review" entry["migration_notes"] = ( "Module owns database migrations; review release notes and migration output before activation." ) if migration.migration_after: entry["migration_after"] = list(migration.migration_after) if migration.migration_before: entry["migration_before"] = list(migration.migration_before) if migration.migration_tasks: entry["migration_tasks"] = [ { "task_id": task.task_id, "phase": task.phase, "summary": task.summary, "task_version": task.task_version, "safety": task.safety, "idempotent": task.idempotent, **({"timeout_seconds": task.timeout_seconds} if task.timeout_seconds is not None else {}), } for task in migration.migration_tasks ] return entry def validate_initial_entry_closure( *, catalog_modules: list[object], initial_module_ids: set[str], ) -> None: entries = [item for item in catalog_modules if isinstance(item, dict)] by_id = {str(item.get("module_id") or ""): item for item in entries} providers: dict[str, list[str]] = {} for entry in entries: for raw in entry.get("provides_interfaces") or (): if isinstance(raw, dict) and isinstance(raw.get("name"), str) and isinstance(raw.get("version"), str): providers.setdefault(raw["name"], []).append(raw["version"]) issues: list[str] = [] for module_id in sorted(initial_module_ids): entry = by_id[module_id] for dependency in entry.get("dependencies") or (): if isinstance(dependency, str) and dependency not in by_id: issues.append(f"{module_id} requires catalog module {dependency!r}") for raw in entry.get("requires_interfaces") or (): if not isinstance(raw, dict) or raw.get("optional") is True: continue name = raw.get("name") if not isinstance(name, str): continue available = providers.get(name, []) if not any( version_satisfies_range( provider_version, version_min=raw.get("version_min") if isinstance(raw.get("version_min"), str) else None, version_max_exclusive=raw.get("version_max_exclusive") if isinstance(raw.get("version_max_exclusive"), str) else None, ) for provider_version in available ): issues.append(f"{module_id} requires unavailable compatible interface {name!r}") if issues: raise ValueError("Initial catalog entry dependency closure failed: " + "; ".join(issues)) def read_project_metadata(path: Path) -> dict[str, object]: if not path.exists(): raise ValueError(f"Cannot synthesize catalog entry: missing {path}.") with path.open("rb") as handle: payload = tomllib.load(handle) project = payload.get("project") if not isinstance(project, dict): raise ValueError(f"Cannot synthesize catalog entry: {path} has no [project] table.") return project def module_entry_points(project: dict[str, object], *, repo: str) -> dict[str, str]: groups = project.get("entry-points") raw = groups.get("govoplan.modules") if isinstance(groups, dict) else None if not isinstance(raw, dict) or not raw: raise ValueError(f"Cannot synthesize {repo}: pyproject has no govoplan.modules entry point.") result = { str(module_id).strip(): str(target).strip() for module_id, target in raw.items() if str(module_id).strip() and str(target).strip() } if len(result) != len(raw): raise ValueError(f"Cannot synthesize {repo}: govoplan.modules contains an empty id or target.") return result def load_manifest(*, root: Path, target: str, repo: str) -> ModuleManifest: module_name, separator, attribute = target.partition(":") if not separator or not module_name or not attribute or "[" in attribute: raise ValueError(f"Cannot synthesize {repo}: unsupported module entry point {target!r}.") src = (root / "src").resolve() expected_module = (src / Path(*module_name.split("."))).with_suffix(".py") expected_package = src / Path(*module_name.split(".")) / "__init__.py" expected = expected_module if expected_module.exists() else expected_package if not expected.exists(): raise ValueError(f"Cannot synthesize {repo}: entry point source {expected_module} is missing.") top_level = module_name.split(".", 1)[0] with isolated_source_import(src=src, top_level=top_level): try: module = importlib.import_module(module_name) except Exception as exc: # noqa: BLE001 - exact load failure is release evidence. raise ValueError(f"Cannot synthesize {repo}: loading {target!r} failed: {exc}") from exc origin = Path(str(getattr(module, "__file__", ""))).resolve() if origin != expected.resolve(): raise ValueError(f"Cannot synthesize {repo}: entry point resolved outside selected source ({origin}).") factory = getattr(module, attribute, None) if not callable(factory): raise ValueError(f"Cannot synthesize {repo}: entry point target {target!r} is not callable.") manifest = factory() if not isinstance(manifest, ModuleManifest): raise ValueError(f"Cannot synthesize {repo}: entry point {target!r} did not return ModuleManifest.") return manifest @contextmanager def isolated_source_import(*, src: Path, top_level: str) -> Iterator[None]: saved: dict[str, ModuleType] = { name: module for name, module in tuple(sys.modules.items()) if name == top_level or name.startswith(f"{top_level}.") } for name in saved: sys.modules.pop(name, None) sys.path.insert(0, str(src)) importlib.invalidate_caches() try: yield finally: sys.path.remove(str(src)) for name in tuple(sys.modules): if name == top_level or name.startswith(f"{top_level}."): sys.modules.pop(name, None) sys.modules.update(saved) importlib.invalidate_caches() def validate_webui_package(*, root: Path, package: str, version: str, repo: str) -> None: declarations: list[tuple[str, str]] = [] for path in (root / "package.json", root / "webui" / "package.json"): if not path.exists(): continue payload = json.loads(path.read_text(encoding="utf-8")) if isinstance(payload, dict) and isinstance(payload.get("name"), str) and isinstance(payload.get("version"), str): declarations.append((payload["name"], payload["version"])) if (package, version) not in declarations: raise ValueError( f"Cannot synthesize {repo}: frontend manifest declares {package}@{version}, but package metadata does not." ) def required_text(payload: dict[str, object], key: str, *, source: str) -> str: value = optional_text(payload.get(key)) if value is None: raise ValueError(f"Cannot synthesize catalog entry: {source} has no {key!r}.") return value def optional_text(value: object) -> str | None: return value.strip() if isinstance(value, str) and value.strip() else None def main() -> int: parser = argparse.ArgumentParser(add_help=False) parser.add_argument("--inspect-repo", required=True) parser.add_argument("--version", required=True) parser.add_argument("--workspace", type=Path, required=True) parser.add_argument("--repository-base", required=True) args = parser.parse_args() try: entries = synthesize_repository_catalog_entries( repo=args.inspect_repo, version=args.version, workspace=args.workspace.resolve(), repository_base=args.repository_base, ) except ValueError as exc: print(str(exc), file=sys.stderr) return 1 print(_INSPECTION_MARKER + json.dumps(entries, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())