57 lines
1.7 KiB
Python
57 lines
1.7 KiB
Python
|
|
"""Parse a construction plan file's YAML frontmatter.
|
||
|
|
|
||
|
|
See docs/construction-plan-format.md. The frontmatter's status field is
|
||
|
|
the phase-4 hard gate: the build executor (executor.py) refuses to run
|
||
|
|
against anything but status: approved with both approved_by/approved_at
|
||
|
|
set.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
|
||
|
|
class PlanError(RuntimeError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class ConstructionPlan:
|
||
|
|
id: str
|
||
|
|
status: str
|
||
|
|
approved_by: str | None
|
||
|
|
approved_at: str | None
|
||
|
|
demand_source: str
|
||
|
|
consumer_repo: str
|
||
|
|
credential_type: str
|
||
|
|
path: Path
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def load(cls, path: str | Path) -> ConstructionPlan:
|
||
|
|
path = Path(path)
|
||
|
|
text = path.read_text()
|
||
|
|
parts = text.split("---", 2)
|
||
|
|
if len(parts) < 3 or not text.startswith("---"):
|
||
|
|
raise PlanError(f"{path}: missing YAML frontmatter")
|
||
|
|
data: dict[str, Any] = yaml.safe_load(parts[1]) or {}
|
||
|
|
missing = {"id", "status"} - set(data)
|
||
|
|
if missing:
|
||
|
|
raise PlanError(f"{path}: frontmatter missing field(s): {', '.join(sorted(missing))}")
|
||
|
|
return cls(
|
||
|
|
id=data["id"],
|
||
|
|
status=data["status"],
|
||
|
|
approved_by=data.get("approved_by"),
|
||
|
|
approved_at=data.get("approved_at"),
|
||
|
|
demand_source=data.get("demand_source", ""),
|
||
|
|
consumer_repo=data.get("consumer_repo", ""),
|
||
|
|
credential_type=data.get("credential_type", ""),
|
||
|
|
path=path,
|
||
|
|
)
|
||
|
|
|
||
|
|
def is_approved(self) -> bool:
|
||
|
|
return self.status == "approved" and bool(self.approved_by) and bool(self.approved_at)
|