runbook-tutorials/rtut/attrs.py

145 lines
5.5 KiB
Python
Raw Normal View History

"""Attribute sets: named, composable, non-secret attribute collections with layered resolution.
An attribute has a *requirement* (required | optional), declared, and a *state*, derived with the same
words as run datapoints: nil (no value), set, err (invalid for a consuming runbook). `modified` marks a
value that a later layer replaced. Every value keeps its source, so a wrong value is traceable.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from jsonschema import Draft202012Validator
from . import pack as P
from .pack import SECRET_RE
SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
def _schema_errors(data: dict, schema_file: str) -> list[str]:
v = Draft202012Validator(json.loads((SCHEMA_DIR / schema_file).read_text()))
return [f"{'/'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
for e in sorted(v.iter_errors(data), key=lambda e: list(map(str, e.absolute_path)))]
def normalize(attrs: dict | None) -> dict[str, dict]:
"""scalar or {value, requirement, note} -> {value, requirement, note}"""
out = {}
for k, v in (attrs or {}).items():
out[k] = dict(v) if isinstance(v, dict) else {"value": v}
out[k].setdefault("value", None)
out[k].setdefault("requirement", None)
return out
@dataclass
class Doc:
path: Path
data: dict
errors: list[str] = field(default_factory=list)
@property
def id(self) -> str:
return str(self.data.get("id", self.path.stem))
@property
def ok(self) -> bool:
return not self.errors
def _load(path: Path, schema_file: str) -> Doc:
try:
data = yaml.safe_load(Path(path).read_text())
except yaml.YAMLError as e:
return Doc(Path(path), {}, [f"invalid YAML: {e}"])
if not isinstance(data, dict):
return Doc(Path(path), {}, ["must be a mapping"])
errs = _schema_errors(data, schema_file)
if not errs and SECRET_RE.search(json.dumps(data)):
errs.append("contains secret-looking marker")
return Doc(Path(path), data, errs)
def load_set(path: Path) -> Doc:
return _load(path, "attribute-set.schema.json")
class Registry:
"""Everything found under one or more repository roots."""
def __init__(self, roots: list[Path]):
self.roots = [Path(r) for r in roots]
self.sets: dict[str, Doc] = {}
self.projects: dict[str, Doc] = {}
self.templates: dict[str, Doc] = {}
self.packs: dict[str, P.Pack] = {}
for r in self.roots:
for f in sorted(r.glob("attribute-sets/*.yaml")):
d = load_set(f); self.sets[d.id] = d
for f in sorted(r.glob("projects/*/project.yaml")):
d = _load(f, "project.schema.json"); self.projects[d.id] = d
for f in sorted(r.glob("project-templates/*.yaml")):
d = _load(f, "project.schema.json"); self.templates[d.id] = d
for f in P.discover(r):
pk = P.load(f); self.packs[pk.id] = pk
def set_layers(reg: Registry, set_id: str, _seen: tuple = ()) -> list[tuple[str, dict]]:
"""Layers of an attribute set, its `extends` first. Raises on unknown sets and cycles."""
if set_id in _seen:
raise ValueError("attribute set cycle: " + " -> ".join(_seen + (set_id,)))
doc = reg.sets.get(set_id)
if doc is None:
raise ValueError(f"unknown attribute set {set_id}")
if not doc.ok:
raise ValueError(f"attribute set {set_id} is invalid: {doc.errors[0]}")
layers: list[tuple[str, dict]] = []
for base in doc.data.get("extends", []):
layers += set_layers(reg, base, _seen + (set_id,))
layers.append((f"set:{set_id}", normalize(doc.data["attributes"])))
return layers
def fold(layers: list[tuple[str, dict]]) -> dict[str, dict]:
"""Later layers win. Keeps provenance and marks a value replaced by a later layer as modified."""
out: dict[str, dict] = {}
for source, attrs in layers:
for name, a in attrs.items():
cur = out.setdefault(name, {"value": None, "requirement": None, "source": None, "modified": False,
"history": [], "note": ""})
if a.get("requirement"):
cur["requirement"] = a["requirement"]
if a.get("note"):
cur["note"] = a["note"]
if a.get("value") is not None:
if cur["value"] is not None and cur["value"] != a["value"]:
cur["modified"] = True
cur["value"] = a["value"]
cur["source"] = source
cur["history"].append({"source": source, "value": a["value"]})
for cur in out.values():
cur["state"] = "set" if cur["value"] is not None else "nil"
return out
def check_value(pack: P.Pack, name: str, value) -> str | None:
"""None if the value is valid for the pack's datapoint/parameter `name`, else the reason."""
defs = {d["id"]: d for d in (pack.data.get("datapoints") or pack.data.get("parameters") or [])}
d = defs.get(name)
if d is None:
return None
v = str(value).lower() if isinstance(value, bool) else str(value)
import re
if d["type"] == "int" and not re.fullmatch(r"-?\d+", v):
return "not an integer"
if d["type"] == "bool" and v not in ("true", "false"):
return "must be true or false"
try:
P.render("{{%s}}" % name, {name: v}, pack)
except (ValueError, KeyError) as e:
return str(e)
return None