463 lines
17 KiB
Python
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))
|