Files
soleprint/soleprint/station/tools/shuntgen/emit.py
2026-08-10 05:36:32 -03:00

463 lines
17 KiB
Python

"""
Shunt emission — turns extracted models into a runnable shunt directory.
The layout follows the contract in artery/shunts/__init__.py (main.py, a depot,
a README) and adds what a generated shunt needs to answer for itself:
artery/shunts/<name>/
main.py builds the app from the spec
run.py uvicorn entry point
shunt_runtime.py vendored copy of runtime.py
models.py pydantic, via modelgen
datagen_<name>.py BaseDataGenerator subclass, via modelgen
depot/spec.json normalised routes + collections + schema
depot/responses.json pinned overrides, the "METHOD /path" map
depot/data.json imported rows
depot/config.json latency / error-injection knobs
templates/index.html config UI
cabinet.json declared dependency containers, if any
README.md
Only spec.json and the depot are worth editing by hand; everything else is
regenerated. The vendored runtime is a copy rather than an import because a
shunt runs standalone, with no soleprint on its path.
"""
import json
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..modelgen.generator import DatagenGenerator, JsonSchemaGenerator, PydanticGenerator
from ..modelgen.loader.schema import (
DatasetDefinition,
EndpointDefinition,
ModelDefinition,
)
HERE = Path(__file__).parent
# soleprint/ — four levels up from station/tools/shuntgen/emit.py
SPR_ROOT = HERE.parents[2]
# Types whose keys are integers, so the runtime coerces "/pets/7" to 7 before
# comparing it with a stored row.
_INT_HINTS = {int, "bigint"}
def _pk_of(model: ModelDefinition) -> tuple[Optional[str], str]:
"""Return (primary key field name, "int" | "str") for a model."""
for field in model.fields:
if getattr(field, "primary_key", False):
return field.name, "int" if field.type_hint in _INT_HINTS else "str"
for field in model.fields:
if field.name == "id":
return field.name, "int" if field.type_hint in _INT_HINTS else "str"
return None, "str"
def _operation(method: str, kind: str) -> str:
"""Name what a route does, from its verb and whether it addresses one row."""
if method == "GET":
return "list" if kind == "collection" else "retrieve" if kind == "item" else "action"
if method == "POST":
return "create" if kind == "collection" else "action"
if method in ("PUT", "PATCH"):
return "update" if kind == "item" else "action"
if method == "DELETE":
return "delete" if kind == "item" else "action"
return "action"
def _class_name(name: str) -> str:
parts = [p for p in name.replace("-", "_").split("_") if p]
return "".join(p[:1].upper() + p[1:] for p in parts) or "Shunt"
def _theme_css() -> str:
"""The theme, inlined.
A shunt serves its own UI on its own port, so it cannot fetch soleprint's
/theme.css. Inlining keeps it standalone and keeps one source of truth —
regenerating picks up any change to common/theme.
"""
theme_dir = SPR_ROOT / "common" / "theme"
parts: List[str] = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(sheet.read_text())
if parts:
return "\n".join(parts)
return ":root{--bg:#0a0a0a;--surface:#1a1a1a;--border:#333;--text:#e5e5e5;--muted:#a3a3a3;--accent:#d4a574}"
class ShuntEmitter:
"""Writes a complete shunt directory."""
def __init__(
self,
name: str,
output: Path,
models: List[ModelDefinition],
enums: Optional[List[Any]] = None,
datasets: Optional[List[DatasetDefinition]] = None,
endpoints: Optional[List[EndpointDefinition]] = None,
title: Optional[str] = None,
source: str = "",
kind: str = "openapi",
port: int = 8099,
cabinets: Optional[List[str]] = None,
):
self.name = name
self.output = Path(output)
self.models = models
self.enums = enums or []
self.datasets = datasets or []
self.endpoints = endpoints or []
self.title = title or _class_name(name)
self.source = source
self.kind = kind
self.port = port
self.cabinets = cabinets or []
self.by_name = {m.name: m for m in models}
# ── entry point ────────────────────────────────────────────────────────
def emit(self) -> Path:
self.output.mkdir(parents=True, exist_ok=True)
(self.output / "depot").mkdir(exist_ok=True)
(self.output / "templates").mkdir(exist_ok=True)
collections = self._collections()
routes = self._routes(collections)
self._write_models()
self._write_generator()
self._write_depot(routes, collections)
self._write_runtime()
self._write_app()
self._write_ui(routes)
self._write_cabinets()
self._write_readme(routes, collections)
return self.output
# ── spec construction ──────────────────────────────────────────────────
def _collections(self) -> Dict[str, dict]:
"""Models the runtime may keep rows for, with the key to match them on."""
out: Dict[str, dict] = {}
for dataset in self.datasets:
model = self.by_name.get(dataset.model)
if not model:
continue
pk, pk_type = _pk_of(model)
out[dataset.model] = {
"path": f"/{dataset.collection or dataset.model.lower()}",
"pk": pk or "id",
"pk_type": pk_type,
"rows": len(dataset.rows),
}
# A spec's models get a collection too, so POSTing to one and GETting it
# back works even though no rows were imported.
for endpoint in self.endpoints:
model_name = endpoint.model
if not model_name or model_name in out:
continue
model = self.by_name.get(model_name)
if not model:
continue
pk, pk_type = _pk_of(model)
if not pk:
continue
out[model_name] = {
"path": self._base_path(endpoint.path),
"pk": pk,
"pk_type": pk_type,
"rows": 0,
}
return out
@staticmethod
def _base_path(path: str) -> str:
"""Trim a path back to its collection — /pets/{petId} -> /pets."""
segments = [s for s in path.split("/") if s and not s.startswith("{")]
return "/" + "/".join(segments) if segments else "/"
def _routes(self, collections: Dict[str, dict]) -> List[dict]:
if self.endpoints:
return [self._from_endpoint(e) for e in self.endpoints]
return self._crud_routes(collections)
def _from_endpoint(self, endpoint: EndpointDefinition) -> dict:
return {
"method": endpoint.method,
"path": endpoint.path,
"operation_id": endpoint.operation_id,
"summary": endpoint.summary,
"operation": _operation(endpoint.method, endpoint.kind),
"model": endpoint.model,
"request_model": endpoint.request_model,
"response_is_list": endpoint.response_is_list,
"envelope_key": endpoint.envelope_key,
"status": endpoint.status,
"path_params": list(endpoint.path_params),
"example": endpoint.example,
}
def _crud_routes(self, collections: Dict[str, dict]) -> List[dict]:
"""The five routes a table implies, for sources that describe no calls."""
routes: List[dict] = []
for model_name, meta in collections.items():
base = meta["path"]
pk = meta["pk"]
item = f"{base}/{{{pk}}}"
plural = base.strip("/") or model_name.lower()
def route(method, path, operation, status, summary, params=()):
return {
"method": method,
"path": path,
"operation_id": f"{operation}_{plural}".replace("-", "_"),
"summary": summary,
"operation": operation,
"model": model_name,
"request_model": model_name if operation in ("create", "update") else None,
"response_is_list": operation == "list",
"envelope_key": None,
"status": status,
"path_params": list(params),
"example": None,
}
routes.extend([
route("GET", base, "list", 200, f"List {plural}"),
route("POST", base, "create", 201, f"Create a {model_name}"),
route("GET", item, "retrieve", 200, f"Fetch one {model_name}", (pk,)),
route("PUT", item, "update", 200, f"Update a {model_name}", (pk,)),
route("DELETE", item, "delete", 204, f"Delete a {model_name}", (pk,)),
])
return routes
# ── file writers ───────────────────────────────────────────────────────
def _write_models(self) -> None:
PydanticGenerator().generate(
(self.models, self.enums), self.output / "models.py"
)
def _generator_module(self) -> str:
return f"datagen_{self.name.replace('-', '_')}"
def _write_generator(self) -> None:
DatagenGenerator(
class_name=f"{_class_name(self.name)}Generator",
depot="depot/data.json",
).generate(
(self.models, self.enums, self.datasets),
self.output / f"{self._generator_module()}.py",
)
def _write_depot(self, routes: List[dict], collections: Dict[str, dict]) -> None:
depot = self.output / "depot"
schema_path = depot / "schema.json"
JsonSchemaGenerator().generate((self.models, self.enums), schema_path)
schema = json.loads(schema_path.read_text())
spec = {
"name": self.name,
"title": self.title,
"kind": self.kind,
"source": self.source,
"summary": f"Generated by shuntgen from {self.source or self.kind}.",
"generator_module": self._generator_module(),
"collections": collections,
"routes": routes,
"models": schema.get("models", {}),
}
(depot / "spec.json").write_text(json.dumps(spec, indent=2) + "\n")
data = {d.model: d.rows for d in self.datasets if d.rows}
(depot / "data.json").write_text(json.dumps(data, indent=2) + "\n")
# Left empty on purpose: an override is a deliberate act, and a file
# pre-filled with guesses would quietly shadow the generated responses.
responses_path = depot / "responses.json"
if not responses_path.exists():
responses_path.write_text("{}\n")
config = {
"title": self.title,
"port": self.port,
"enable_random_delays": False,
"min_delay_ms": 200,
"max_delay_ms": 800,
"error_rate": 0.0,
# Imported rows are the real thing; only invent when there are none.
"prefill": 0 if any(d.rows for d in self.datasets) else 5,
"unknown_id": "generate",
"page_size": 50,
}
config_path = depot / "config.json"
if not config_path.exists():
config_path.write_text(json.dumps(config, indent=2) + "\n")
def _write_runtime(self) -> None:
shutil.copyfile(HERE / "runtime.py", self.output / "shunt_runtime.py")
def _write_app(self) -> None:
(self.output / "main.py").write_text(
f'''"""
{self.title} shunt — GENERATED.
The routes live in depot/spec.json and are built at import time by
shunt_runtime. Edit the depot, not this file; regenerate with shuntgen.
"""
from pathlib import Path
from shunt_runtime import build_app
app = build_app(Path(__file__).parent)
'''
)
(self.output / "run.py").write_text(
f'''"""Run the {self.title} shunt standalone."""
import json
import os
from pathlib import Path
import uvicorn
BASE = Path(__file__).parent
def port() -> int:
"""PORT wins, then depot/config.json, then the generated default."""
if os.getenv("PORT"):
return int(os.environ["PORT"])
config = BASE / "depot" / "config.json"
if config.exists():
try:
return int(json.loads(config.read_text()).get("port", {self.port}))
except (OSError, ValueError):
pass
return {self.port}
if __name__ == "__main__":
chosen = port()
print(f"{self.title} shunt on http://localhost:{{chosen}} (UI at /, spec at /mock/spec)")
uvicorn.run("main:app", host="0.0.0.0", port=chosen, reload=False)
'''
)
def _write_ui(self, routes: List[dict]) -> None:
template = (HERE / "templates" / "shunt_ui.html").read_text()
page = (
template.replace("%%THEME_CSS%%", _theme_css())
.replace("%%TITLE%%", self.title)
.replace("%%NAME%%", self.name)
.replace("%%SOURCE%%", self.source or self.kind)
.replace("%%ROUTE_COUNT%%", str(len(routes)))
)
(self.output / "templates" / "index.html").write_text(page)
def _write_cabinets(self) -> None:
if not self.cabinets:
return
(self.output / "cabinet.json").write_text(
json.dumps(
{
"requires": self.cabinets,
"note": (
"Dependency containers this shunt expects. "
"`python build.py --cfg <room>` composes them into the "
"room's docker-compose.yml; on a cluster they install "
"as rig addons of the same name."
),
},
indent=2,
)
+ "\n"
)
def _write_readme(self, routes: List[dict], collections: Dict[str, dict]) -> None:
lines = [
f"# {self.title} shunt",
"",
f"Generated by shuntgen from `{self.source or self.kind}`.",
"",
"## Run",
"",
"```bash",
f"python run.py # http://localhost:{self.port}",
f"PORT=9000 python run.py # somewhere else",
"```",
"",
"## Routes",
"",
"| Method | Path | Does |",
"| --- | --- | --- |",
]
for route in routes:
lines.append(
f"| {route['method']} | `{route['path']}` | {route['operation']} |"
)
lines += [
"",
"## Control",
"",
"| Endpoint | Purpose |",
"| --- | --- |",
"| `GET /health` | liveness |",
"| `GET /mock/spec` | the routes this shunt was built from |",
"| `GET /mock/stats` | call counts and row counts |",
"| `POST /mock/reset` | restore the imported rows, clear counters |",
"| `GET,POST /mock/config` | latency and error-injection knobs |",
"| `GET,POST /mock/responses` | pin an override; set a key to `null` to drop it |",
"",
"## Depot",
"",
"| File | Purpose |",
"| --- | --- |",
"| `spec.json` | routes, collections and schema — the source of truth |",
"| `responses.json` | pinned overrides, keyed `\"METHOD /path\"`; these win over everything |",
"| `data.json` | seed rows, keyed by model |",
"| `config.json` | delays, error rate, prefill, page size |",
"",
]
if collections:
lines += ["## Collections", "", "| Model | Path | Key | Seed rows |", "| --- | --- | --- | --- |"]
for model, meta in collections.items():
lines.append(
f"| {model} | `{meta['path']}` | `{meta['pk']}` | {meta['rows']} |"
)
lines.append("")
if self.cabinets:
lines += [
"## Dependencies",
"",
f"Declares the cabinets: {', '.join(f'`{c}`' for c in self.cabinets)}. "
"See `cabinet.json`.",
"",
]
lines += [
"## Regenerating",
"",
"Everything here except `depot/responses.json` and `depot/config.json` is",
"overwritten on regeneration — those two are yours.",
"",
]
(self.output / "README.md").write_text("\n".join(lines))