"""Data models for GovOPlaN release tooling. The models intentionally stay dataclass-based so both the CLI and the local web console can use them without depending on a web framework or Pydantic. """ from __future__ import annotations from dataclasses import asdict, dataclass, is_dataclass from pathlib import Path from typing import Any @dataclass(frozen=True, slots=True) class RepositorySpec: name: str category: str subtype: str remote: str path: str @dataclass(frozen=True, slots=True) class VersionSnapshot: pyproject: str | None = None package: str | None = None webui_package: str | None = None manifests: tuple[str, ...] = () @property def primary(self) -> str | None: return self.pyproject or self.package or self.webui_package or (self.manifests[0] if self.manifests else None) @dataclass(frozen=True, slots=True) class RepositorySnapshot: spec: RepositorySpec absolute_path: str exists: bool is_git: bool has_head: bool = False head: str | None = None branch: str | None = None upstream: str | None = None ahead: int | None = None behind: int | None = None dirty_entries: tuple[str, ...] = () versions: VersionSnapshot = VersionSnapshot() local_target_tag_exists: bool | None = None remote_target_tag_exists: bool | None = None remote_checked: bool = False safe_directory_required: bool = False safe_directory_command: str | None = None errors: tuple[str, ...] = () @property def dirty(self) -> bool: return bool(self.dirty_entries) @dataclass(frozen=True, slots=True) class CatalogSnapshot: channel: str website_path: str catalog_path: str catalog_exists: bool keyring_path: str keyring_exists: bool catalog_version: str | None = None core_release_version: str | None = None module_count: int = 0 signature_count: int = 0 key_count: int = 0 generated_at: str | None = None expires_at: str | None = None catalog_error: str | None = None keyring_error: str | None = None local_catalog_hash: str | None = None local_keyring_hash: str | None = None public_catalog_url: str | None = None public_keyring_url: str | None = None public_checked: bool = False public_catalog_exists: bool | None = None public_keyring_exists: bool | None = None public_core_release_version: str | None = None public_module_count: int | None = None public_signature_count: int | None = None public_key_count: int | None = None public_catalog_hash: str | None = None public_keyring_hash: str | None = None public_catalog_error: str | None = None public_keyring_error: str | None = None catalog_matches_public: bool | None = None keyring_matches_public: bool | None = None @dataclass(frozen=True, slots=True) class MigrationTrackSnapshot: track: str ok: bool ran: bool graph_errors: tuple[str, ...] = () strict_errors: tuple[str, ...] = () error: str | None = None @dataclass(frozen=True, slots=True) class DashboardSummary: repository_count: int missing_count: int dirty_count: int ahead_count: int behind_count: int no_head_count: int error_count: int safe_directory_count: int local_target_tag_missing_count: int status: str @dataclass(frozen=True, slots=True) class ReleaseDashboard: generated_at: str meta_root: str workspace_root: str target_version: str | None target_tag: str | None online: bool include_migrations: bool summary: DashboardSummary repositories: tuple[RepositorySnapshot, ...] catalog: CatalogSnapshot migrations: tuple[MigrationTrackSnapshot, ...] = () contracts: tuple[ModuleContractSnapshot, ...] = () @dataclass(frozen=True, slots=True) class PlanAction: id: str title: str detail: str command: str | None = None cwd: str | None = None mutating: bool = False severity: str = "info" repo: str | None = None @dataclass(frozen=True, slots=True) class ReleasePlan: generated_at: str target_version: str | None target_tag: str | None status: str actions: tuple[PlanAction, ...] @dataclass(frozen=True, slots=True) class InterfaceProviderSnapshot: name: str version: str @dataclass(frozen=True, slots=True) class InterfaceRequirementSnapshot: name: str version_min: str | None = None version_max_exclusive: str | None = None optional: bool = False @dataclass(frozen=True, slots=True) class ModuleContractSnapshot: repo: str module_id: str module_version: str | None manifest_path: str provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = () requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = () @dataclass(frozen=True, slots=True) class ReleasePlanUnit: repo: str category: str subtype: str branch: str | None current_version: str | None target_version: str target_tag: str status: str blockers: tuple[str, ...] = () warnings: tuple[str, ...] = () provides_interfaces: tuple[InterfaceProviderSnapshot, ...] = () requires_interfaces: tuple[InterfaceRequirementSnapshot, ...] = () @dataclass(frozen=True, slots=True) class CompatibilityIssue: severity: str code: str message: str repo: str | None = None module_id: str | None = None interface: str | None = None @dataclass(frozen=True, slots=True) class ReleasePlanStep: id: str title: str detail: str command: str | None = None cwd: str | None = None mutating: bool = False repo: str | None = None status: str = "planned" @dataclass(frozen=True, slots=True) class SelectiveReleasePlan: generated_at: str target_channel: str status: str units: tuple[ReleasePlanUnit, ...] compatibility: tuple[CompatibilityIssue, ...] dry_run_steps: tuple[ReleasePlanStep, ...] notes: tuple[str, ...] = () @dataclass(frozen=True, slots=True) class CatalogEntryChange: repo: str module_id: str | None field: str before: str | None after: str | None @dataclass(frozen=True, slots=True) class SelectiveCatalogCandidate: generated_at: str channel: str status: str candidate_dir: str catalog_path: str keyring_path: str summary_path: str | None source_catalog: str public_catalog_url: str public_keyring_url: str sequence: int signature_count: int key_count: int candidate_catalog_hash: str candidate_keyring_hash: str published_catalog_hash: str | None published_keyring_hash: str | None candidate_matches_published_catalog: bool | None candidate_matches_published_keyring: bool | None validation_valid: bool validation_error: str | None = None validation_warnings: tuple[str, ...] = () changes: tuple[CatalogEntryChange, ...] = () notes: tuple[str, ...] = () @dataclass(frozen=True, slots=True) class CatalogPublishStep: id: str title: str detail: str command: str | None = None mutating: bool = False status: str = "planned" @dataclass(frozen=True, slots=True) class CatalogPublishResult: generated_at: str channel: str status: str applied: bool candidate_dir: str candidate_catalog_path: str candidate_keyring_path: str web_root: str target_catalog_path: str target_keyring_path: str branch: str | None tag_name: str | None validation_valid: bool validation_error: str | None = None validation_warnings: tuple[str, ...] = () candidate_catalog_hash: str | None = None candidate_keyring_hash: str | None = None target_catalog_hash_before: str | None = None target_keyring_hash_before: str | None = None catalog_changed: bool = False keyring_changed: bool = False steps: tuple[CatalogPublishStep, ...] = () notes: tuple[str, ...] = () def to_jsonable(value: Any) -> Any: if is_dataclass(value): return {key: to_jsonable(item) for key, item in asdict(value).items()} if isinstance(value, tuple | list): return [to_jsonable(item) for item in value] if isinstance(value, dict): return {str(key): to_jsonable(item) for key, item in value.items()} if isinstance(value, Path): return str(value) return value