Compare commits

...

6 Commits

Author SHA1 Message Date
910927993e updates 33.1 139 2026-08-10 09:19:19 -03:00
9a6337e493 updates 33.1 84 2026-08-10 05:36:32 -03:00
0b04516cbb updates 33.1 56 2026-08-10 03:44:50 -03:00
ef63b02554 update 33.1 50 2026-08-10 03:23:30 -03:00
33f0559268 updates 33.1 23 2026-08-10 01:35:13 -03:00
dfb1991ae3 updates 33.1 8 2026-08-10 00:32:47 -03:00
117 changed files with 9848 additions and 944 deletions

16
.gitignore vendored
View File

@@ -9,9 +9,25 @@ __pycache__/
.venv/
venv/
# Secrets. There was no rule here, which is how a real API key ended up tracked
# in station/tools/tester/.env. Templates still ship.
.env
!.env.example
# Node
node_modules/
# Built library bundles (regenerate with `pnpm build` in the package).
# The dist is what a container boots; it is an artifact, not source.
dist/
# Generated runnable instance (entirely gitignored - regenerate with build.py)
gen/
# Specs and sheets uploaded through shuntgen's UI. Inputs someone dropped in a
# browser, not source — and often a client's real data.
soleprint/station/tools/shuntgen/uploads/
# Room configurations (separate repo - contains credentials and room-specific data)
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
cfg/amar/

147
CLAUDE.md
View File

@@ -51,20 +51,19 @@ spr/
│ └── amar/ # Amar room config
│ ├── config.json
│ ├── data/
│ ├── artery/ # Amar-specific (merged into output)
│ │ ── shunts/amar/
├── atlas/ # Amar-specific books
│ │ ── books/
├── station/ # Amar-specific tools config
│ │ ── tools/datagen/
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
│ │ ── artery/ # room shunts, pulses
└── shunts/amar/
│ │ ── atlas/ # room books
└── books/
│ │ ── station/ # room tool configs
│ │ │ └── tools/datagen/
│ │ └── nginx/
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
│ ├── link/ # Bridge to managed app
── soleprint/ # Soleprint docker config
│ ├── databrowse/
│ ├── tester/
│ ├── monitors/
│ └── models/
── amar/ # The managed app itself
├── ctrl/ # Build/run scripts
├── ctrl/ # Build/run scripts (see Build & Run)
└── gen/ # Built instances (gitignored)
├── standalone/
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
- `config.json` - Framework branding/terminology
- `data/` - Data files (veins.json, shunts.json, etc.)
Room-specific system configs (merged into output):
- `artery/` - Room-specific shunts, pulses
- `atlas/` - Room-specific books
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
the core `soleprint/` tree at build time:
- `soleprint/artery/` - Room-specific shunts, pulses
- `soleprint/atlas/` - Room-specific books
- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests)
A room's own lifecycle scripts live in `cfg/<room>/ctrl/` and land in
`gen/<room>/ctrl/` — that is what `make start <room>` dispatches to.
## Build & Run
`make` is the front door — one target per `ctrl/` script, with the subcommand as an
argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts,
never in the Makefile. `make help` lists every target.
```bash
# Build
python build.py # -> gen/standalone/
python build.py --cfg amar # -> gen/amar/
python build.py --all # -> all rooms
make # = make help
make build [room|all|models] # -> gen/<room>/ (default: standalone)
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
make stop [room]
make cluster [up|down|status] # the shared `spr` kind cluster
make component [list|sync|watch|publish|diff]
make deploy [--build|--sync-only]
```
# Run bare-metal
cd gen/standalone && python run.py
Every script stays runnable on its own — the standalone rule holds:
# Using ctrl scripts
./ctrl/build.sh [room]
./ctrl/start.sh [room] [-d]
./ctrl/stop.sh [room]
```bash
python build.py --cfg amar # -> gen/amar/
cd gen/standalone && python run.py # bare-metal
./ctrl/kind-up.sh # still works directly
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
```
## Adding a New Room
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
cp cfg/standalone/config.json cfg/newroom/
cp -r cfg/standalone/data/* cfg/newroom/data/
# Add room-specific configs as needed:
# cfg/newroom/artery/shunts/...
# cfg/newroom/atlas/books/...
# cfg/newroom/station/tools/...
# Add room-specific configs as needed (note the soleprint/ overlay level):
# cfg/newroom/soleprint/artery/shunts/...
# cfg/newroom/soleprint/atlas/books/...
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
python build.py --cfg newroom
make build newroom
```
## Ports
@@ -149,10 +160,51 @@ python build.py --cfg newroom
|------|---------|
| modelgen | Generate models from config |
| datagen | Generate test data (uses faker) |
| tester | BDD/playwright test runner |
| tester | HTTP contract test runner |
| graphgen | Generate navigable model graphs |
| databrowse | SQL data browser |
## Shared UI (`soleprint/common/ui`)
Upstream copy of the `soleprint-ui` package. `mpr/ui/framework` and
`meetus/ui/framework` are **downstream copies** synced by `ctrl/spr.py` — edit here,
never there, or the next sync overwrites the change.
```bash
cd soleprint/common/ui
pnpm install && pnpm typecheck && pnpm test
pnpm build # -> dist/soleprint-ui.js + dist/style.css
```
**The theme ships with the bundle.** Every component styles itself with
`var(--surface-0)` and friends from `src/tokens.css`, so `src/index.ts` imports
`src/theme.css` (tokens + `base.css`). A bundle without it renders broken, not merely
unbranded. Retheme by overriding the variables in `tokens.css`; don't inline colours
in components.
`dist/` and `node_modules/` are gitignored — the dist is an artifact a container
boots, not source. To hand someone a running artifact without the sources:
```bash
python ctrl/spr.py publish soleprint-ui <dest> --dist # bundle + manifest only
```
## Tests
**No test bodies are committed to core.** `soleprint/station/tools/tester/` ships the
base class, runner and UI; tests belong to a room at
`cfg/<room>/soleprint/station/tools/tester/tests/`.
Two rules, both in `tester/tests/test_template.py`:
1. **One suite, any environment** — a test states what the API promises, never who
implements it or where it runs.
2. **No helper framework tools** — stdlib `unittest` + `httpx`. No pytest fixtures,
no framework test client, no factories, no ORM or database access.
Django, where it appears, is an optional private DB editor via its admin — never the
framework, and never something a test reaches into.
## External Paths
| What | Path |
@@ -161,6 +213,35 @@ python build.py --cfg newroom
## Files Ignored
- `gen/` - Regenerate with `python build.py`
- `gen/` - Regenerate with `make build [room]`
- `dist/` - Build artifact; regenerate with `pnpm build`
- `node_modules/`
- `fails/`, `def/` - Drafts
- `__pycache__/`, `.venv/`
## Known Broken (found, not yet fixed)
- `tester/tests/example/test_health.py` imports `pytest`, which is neither a
dependency nor allowed here — `tests/README.md` says stdlib `unittest` and
`httpx`, no pytest. It fails to import, so `python -m tester discover` reports
it as `_FailedTest`. It also duplicates `test_template.py`, which is the
sanctioned example. Removing it is probably the fix; core ships no tests.
- Old vocabulary survives in prose — `album`, `larder`, `ward`, `nest` and
`pawprint` still appear in READMEs, `docs/`, and this file's history. Live code
is clean; the docs lag.
## Fixed
- **A real API key was tracked** in `station/tools/tester/.env`. Untracked, `.env`
added to `.gitignore`, `.env.example` added. **The key is still in git history
and must be rotated** — untracking does not unpublish it.
- Client vocabulary is out of live code. `atlas/main.py` was written entirely as
`Album`/`larder`/`PAWPRINT_URL`; `databrowse` docs described a `larder/` the
code stopped using; `get-api-key.sh` defaulted to the client's database name.
- Two dead back-links, found while renaming: `atlas/main.py` passed
`pawprint_url` and `artery/index.html` read `pawprint_url`, while `run.py`
passes `soleprint_url`. Neither "← Soleprint" link had ever rendered.
- `atlas/main.py` fetched `/api/data/album`; `main.py` serves `/api/data/atlas`.
`get_data()` had been failing and returning empty lists.
- `tester/tests/_dev/test_health.py` imported a non-existent `..endpoints`. The
import was unused; removing it makes the module discoverable (2 tests).

62
Makefile Normal file
View File

@@ -0,0 +1,62 @@
# Thin control Makefile — one target per ctrl/ script, and the subcommand is an
# argument rather than a second target: `make cluster down`, not `make cluster-down`.
#
# The logic lives in the scripts, never here. Each target maps to exactly one
# file, and that file holds the variants:
#
# make cluster up -> ctrl/cluster.sh up
# make build amar -> ctrl/build.sh amar
#
# Bare words pass straight through. Anything starting with a dash would be
# swallowed by make itself, so pass those via ARGS instead:
#
# make component ARGS="publish soleprint-ui /tmp/out --dist"
# make deploy ARGS="--build"
#
# Every script stays runnable on its own (./ctrl/kind-up.sh still works, and each
# built room keeps its own gen/<room>/ctrl/*.sh) — the standalone rule holds, and
# this only saves typing.
#
# Start with: make build && make start
# The room to act on when none is named. Rooms live in cfg/ and build into gen/.
ROOM ?= standalone
PYTHON ?= python3
export PYTHON
# Words after the target become the script's subcommand. Make would otherwise
# treat them as goals of their own, so each gets a no-op rule.
ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
ifneq ($(ARGS),)
$(eval $(ARGS):;@:)
endif
.DEFAULT_GOAL := help
.PHONY: help build start stop cluster deploy component
help: ## list targets
@grep -hE '^[a-z]+:.*?##' $(MAKEFILE_LIST) | sed 's/:.*##/\t/' | expand -t16
# ── rooms ──────────────────────────────────────────────────────────────────
build: ## build a room into gen/ [<room>|all|models]
bash ctrl/build.sh $(or $(ARGS),$(ROOM))
start: ## run a built room [<room>] [-d] [--build]
bash ctrl/start.sh $(or $(ARGS),$(ROOM))
stop: ## stop a running room [<room>]
bash ctrl/stop.sh $(or $(ARGS),$(ROOM))
# ── cluster ────────────────────────────────────────────────────────────────
cluster: ## shared kind cluster [up|down|status] (default status)
bash ctrl/cluster.sh $(or $(ARGS),status)
# ── distribution ───────────────────────────────────────────────────────────
component: ## publish components [list|sync|watch|publish|diff]
$(PYTHON) ctrl/spr.py $(or $(ARGS),list)
deploy: ## push standalone to the server [--build|--sync-only]
bash ctrl/deploy.sh $(ARGS)

226
build.py
View File

@@ -317,6 +317,221 @@ def copy_cfg(output_dir: Path, room: str):
copy_path(item, output_dir / item.name)
def load_cabinets(room: str) -> list[dict]:
"""The dependency containers a room asked for, in the order it listed them.
Read from cfg/<room>/data/cabinets.json — the same shape and the same place
as its sibling data/*.json files, so nothing new has to know about it.
Entries are {"name": "postgres"} and may carry an "env" override.
"""
path = SPR_ROOT / "cfg" / room / "data" / "cabinets.json"
if not path.exists():
return []
try:
entries = json.loads(path.read_text())
except ValueError as e:
log.warning(f" cabinets.json is not valid JSON, ignoring: {e}")
return []
out = []
for entry in entries if isinstance(entries, list) else []:
if isinstance(entry, str):
entry = {"name": entry}
if isinstance(entry, dict) and entry.get("name"):
out.append(entry)
return out
def resolve_cabinets(requested: list[dict]) -> list[dict]:
"""Expand each request into its definition, pulling in what it depends on.
Airflow without postgres is a container that exits on boot, so a cabinet's
depends_on is added for you rather than left as something to remember.
"""
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
resolved: dict[str, dict] = {}
def add(name: str, overrides: dict) -> None:
if name in resolved:
# Already pulled in as somebody's dependency. The room asking for it
# by name is the more specific statement, so its env still applies —
# otherwise declaring airflow before postgres would silently drop
# postgres's settings.
if overrides.get("env"):
resolved[name]["env"] = {
**resolved[name].get("env", {}),
**overrides["env"],
}
return
definition_path = cabinets_dir / name / "cabinet.json"
if not definition_path.exists():
available = sorted(
p.name for p in cabinets_dir.iterdir() if p.is_dir()
) if cabinets_dir.exists() else []
log.warning(f" no such cabinet: {name} (available: {', '.join(available) or 'none'})")
return
try:
definition = json.loads(definition_path.read_text())
except ValueError as e:
log.warning(f" cabinet {name} has invalid cabinet.json: {e}")
return
# Mark it claimed before recursing, so a dependency cycle terminates.
resolved[name] = definition
for dependency in definition.get("depends_on", []) or []:
add(dependency, {})
definition["env"] = {**definition.get("env", {}), **overrides.get("env", {})}
for entry in requested:
add(entry["name"], entry)
# Dependencies first, so compose reads in the order things start.
ordered: list[dict] = []
seen: set[str] = set()
def emit(name: str) -> None:
if name in seen or name not in resolved:
return
seen.add(name)
for dependency in resolved[name].get("depends_on", []) or []:
emit(dependency)
ordered.append(resolved[name])
for name in resolved:
emit(name)
return ordered
def compose_cabinets(output_dir: Path, room: str):
"""Merge the room's cabinets into its docker-compose.yml and .env.example.
This is the compile step for dependencies: a room declares postgres, and the
built instance comes out with postgres in its compose file rather than with
instructions for adding it.
"""
requested = load_cabinets(room)
if not requested:
return
try:
import yaml
except ImportError:
log.warning(
" cabinets need PyYAML to merge into docker-compose.yml "
"(pip install pyyaml) — skipping"
)
return
cabinets = resolve_cabinets(requested)
if not cabinets:
return
compose_path = output_dir / "docker-compose.yml"
if not compose_path.exists():
log.warning(
f" no docker-compose.yml in {output_dir.name}, "
f"so there is nothing to merge {len(cabinets)} cabinet(s) into"
)
return
original = compose_path.read_text()
# A YAML round-trip drops every comment, and the room's compose file leads
# with the one that says how to run it. Keep the header block; the rest is
# generated anyway.
header = []
for line in original.splitlines():
if line.startswith("#") or not line.strip():
header.append(line)
else:
break
while header and not header[-1].strip():
header.pop()
compose = yaml.safe_load(original) or {}
services = compose.setdefault("services", {})
volumes = compose.setdefault("volumes", {}) or {}
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
added, skipped = [], []
for cabinet in cabinets:
name = cabinet["name"]
service_name = cabinet.get("service", name)
# The room's own compose file is the authority. A room that already
# declares `db` has arranged it deliberately, and silently replacing it
# would be the worst possible outcome of switching a cabinet on.
if service_name in services:
skipped.append(service_name)
continue
fragment_path = cabinets_dir / name / "service.yml"
if not fragment_path.exists():
log.warning(f" cabinet {name} has no service.yml")
continue
fragment = yaml.safe_load(fragment_path.read_text()) or {}
for key, value in fragment.items():
if key in services:
skipped.append(key)
continue
services[key] = value
added.append(key)
for volume in cabinet.get("volumes", []) or []:
volumes.setdefault(volume, None)
if volumes:
compose["volumes"] = volumes
rendered = yaml.safe_dump(compose, sort_keys=False, default_flow_style=False)
banner = f"# Cabinets merged in by build.py: {', '.join(c['name'] for c in cabinets)}.\n"
preamble = ("\n".join(header) + "\n" + banner + "\n") if header else banner + "\n"
compose_path.write_text(preamble + rendered)
if added:
log.info(f" cabinets: {', '.join(added)}")
if skipped:
log.info(f" cabinets already declared by the room, left alone: {', '.join(skipped)}")
_append_cabinet_env(output_dir, cabinets)
def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
"""Add each cabinet's settings to .env.example, without touching .env."""
example = output_dir / ".env.example"
existing = example.read_text() if example.exists() else ""
# Match whole settings, not substrings: `POSTGRES_DB=` appears inside
# `MY_POSTGRES_DB=`, and a substring test would decide the setting was
# already there and skip it.
declared = {
line.split("=", 1)[0].strip()
for line in existing.splitlines()
if "=" in line and not line.lstrip().startswith("#")
}
lines = []
for cabinet in cabinets:
env = cabinet.get("env", {})
if not env:
continue
block = [f"\n# ── {cabinet.get('title', cabinet['name'])} (cabinet) ──"]
for note in cabinet.get("notes", []) or []:
block.append(f"# {note}")
wrote = False
for key, value in env.items():
if key in declared:
continue
block.append(f"{key}={value}")
declared.add(key)
wrote = True
if wrote:
lines.extend(block)
if lines:
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
def build_soleprint(output_dir: Path, room: str):
"""Build soleprint folder with core + room config merged."""
soleprint = SPR_ROOT / "soleprint"
@@ -348,6 +563,11 @@ def build_soleprint(output_dir: Path, room: str):
# Room config (includes merging room-specific artery/atlas/station)
copy_cfg(output_dir, room)
# Dependency containers the room asked for, merged into its compose file.
# After copy_cfg, because the compose file being merged into is the room's.
log.info("Composing cabinets...")
compose_cabinets(output_dir, room)
# Generate models
log.info("Generating models...")
if not generate_models(output_dir, room):
@@ -419,7 +639,11 @@ def main():
elif args.all:
build(SPR_ROOT / "gen" / "standalone", None)
for room in (SPR_ROOT / "cfg").iterdir():
if room.is_dir() and room.name not in ("__pycache__", "standalone"):
# cfg/ is itself a git repo and rooms may carry dot-dirs — skip them,
# or --all tries to build ".git" as a room.
if room.name.startswith(".") or room.name == "__pycache__":
continue
if room.is_dir() and room.name != "standalone":
build(SPR_ROOT / "gen" / room.name, room.name)
else:
if args.output:

View File

@@ -1,5 +1,5 @@
"""
Pawprint Data Layer
Soleprint Data Layer
JSON file storage (future: MongoDB)
"""

View File

@@ -1,5 +1,5 @@
"""
Pawprint Data Layer
Soleprint Data Layer
JSON file storage (future: MongoDB)
"""

View File

@@ -1,5 +1,5 @@
{
"items": [
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"}
{"name": "standalone-local", "slug": "standalone-local", "title": "Standalone Local", "status": "dev", "config_path": "deploy/standalone-local"}
]
}

37
ctrl/build.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Build a room into gen/ — thin wrapper over build.py.
#
# Usage:
# ./ctrl/build.sh # standalone -> gen/standalone/
# ./ctrl/build.sh amar # amar -> gen/amar/
# ./ctrl/build.sh all # every room under cfg/
# ./ctrl/build.sh models # only regenerate models
#
# build.py holds the logic; this exists so `make build` has exactly one
# script to call, and so the room name is a plain argument.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$ROOT_DIR"
PYTHON="${PYTHON:-python3}"
ROOM="${1:-standalone}"
case "$ROOM" in
all) exec "$PYTHON" build.py --all ;;
models) exec "$PYTHON" build.py --models ;;
esac
if [[ ! -d "cfg/$ROOM" ]]; then
echo "No such room: cfg/$ROOM" >&2
echo "Available: $(find cfg -mindepth 1 -maxdepth 1 -type d -not -name '.*' -printf '%f ')" >&2
exit 1
fi
# standalone is build.py's default and takes no --cfg
if [[ "$ROOM" == "standalone" ]]; then
exec "$PYTHON" build.py
fi
exec "$PYTHON" build.py --cfg "$ROOM"

24
ctrl/cluster.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# The shared `spr` kind cluster [up|down|status] (default status).
#
# Usage:
# ./ctrl/cluster.sh up # create the cluster (no-op if it exists)
# ./ctrl/cluster.sh down # delete it (drops every room's namespace)
# ./ctrl/cluster.sh status # what's running on it
#
# One target, one script — the variants live here. The kind-*.sh files stay
# exactly as they are and remain runnable on their own; this only dispatches.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
case "${1:-status}" in
up) exec "$SCRIPT_DIR/kind-up.sh" ;;
down) exec "$SCRIPT_DIR/kind-down.sh" ;;
status) exec "$SCRIPT_DIR/kind-status.sh" ;;
*)
echo "Unknown subcommand: $1" >&2
echo "Usage: cluster.sh [up|down|status]" >&2
exit 1
;;
esac

View File

@@ -11,6 +11,7 @@ Usage:
python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework
python ctrl/spr.py watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop
python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework
python ctrl/spr.py publish soleprint-ui /tmp/out --dist # built bundle only
python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework
"""
@@ -194,6 +195,37 @@ def cmd_list(args):
log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"])
def publish_dist(source, dest):
"""Copy only the built bundle — dist/ plus the manifest and any docs.
The artifact-only form: a consumer gets something that runs, not the
sources. Deliberately NOT a variant of copy_tree, which walks the whole
tree and strips dist; here dist is the entire point.
"""
dist = source / "dist"
if not dist.is_dir() or not any(dist.iterdir()):
log.error("no build at %s", dist)
log.info("build it first: cd %s && pnpm build", source)
sys.exit(1)
count = 0
dest.mkdir(parents=True, exist_ok=True)
for item in dist.rglob("*"):
if item.is_file():
target = dest / "dist" / item.relative_to(dist)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
count += 1
# The manifest travels too, or the bundle's entry points aren't resolvable.
for name in ("package.json", "README.md", "LICENSE"):
if (source / name).is_file():
shutil.copy2(source / name, dest / name)
count += 1
return count
def cmd_publish(args):
registry = load_registry()
comp_type, source = resolve_component(registry, args.component)
@@ -202,12 +234,17 @@ def cmd_publish(args):
if dest.exists():
shutil.rmtree(dest)
if args.dist:
count = publish_dist(source, dest)
mode = "published-dist"
else:
count = copy_tree(source, dest)
write_stamp(dest, args.component, comp_type, source, "published")
mode = "published"
write_stamp(dest, args.component, comp_type, source, mode)
version = get_version(comp_type, source)
sha = get_sha()
log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count)
log.info("%s v%s (%s) -> %s (%d files, %s)", args.component, version, sha, dest, count, mode)
def cmd_sync(args):
@@ -306,6 +343,12 @@ def main():
p = sub.add_parser(cmd)
p.add_argument("component", help="component name")
p.add_argument("dest", help="target folder path")
if cmd == "publish":
p.add_argument(
"--dist",
action="store_true",
help="ship only the built bundle (dist/ + manifest), not the sources",
)
p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
p.add_argument("component", help="component name")

35
ctrl/start.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Start a built room by dispatching to its own ctrl/start.sh in gen/.
#
# Usage:
# ./ctrl/start.sh # standalone, foreground
# ./ctrl/start.sh amar -d # amar, detached
# ./ctrl/start.sh sample --build # flags pass straight through
#
# Every room ships its own start script (gen/<room>/ctrl/start.sh) and stays
# runnable on its own — this only saves cd'ing there and picks the default room.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOM="standalone"
if [[ $# -gt 0 && "$1" != -* ]]; then
ROOM="$1"
shift
fi
ROOM_DIR="$ROOT_DIR/gen/$ROOM"
if [[ ! -d "$ROOM_DIR" ]]; then
echo "Room '$ROOM' is not built — run: ./ctrl/build.sh $ROOM" >&2
exit 1
fi
if [[ ! -x "$ROOM_DIR/ctrl/start.sh" ]]; then
echo "No start script at gen/$ROOM/ctrl/start.sh" >&2
echo "(rebuild the room, or start it by hand from $ROOM_DIR)" >&2
exit 1
fi
exec "$ROOM_DIR/ctrl/start.sh" "$@"

27
ctrl/stop.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Stop a running room by dispatching to its own ctrl/stop.sh in gen/.
#
# Usage:
# ./ctrl/stop.sh # standalone
# ./ctrl/stop.sh amar # a named room
#
# Mirror of ctrl/start.sh — the room's own script does the work.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
ROOM="standalone"
if [[ $# -gt 0 && "$1" != -* ]]; then
ROOM="$1"
shift
fi
ROOM_DIR="$ROOT_DIR/gen/$ROOM"
if [[ ! -x "$ROOM_DIR/ctrl/stop.sh" ]]; then
echo "No stop script at gen/$ROOM/ctrl/stop.sh — nothing to stop." >&2
exit 0
fi
exec "$ROOM_DIR/ctrl/stop.sh" "$@"

124
docs/data/en/export.md Normal file
View File

@@ -0,0 +1,124 @@
# Export / Compile
Soleprint's source tree is not what runs. `build.py` compiles the framework plus
a room's configuration into a self-contained instance under `gen/<room>/`, and
that directory is what a container boots, what `deploy.sh` rsyncs, and what the
cluster manifests point at.
Everything below is a `make` target, and every target is one script in `ctrl/`.
The logic lives in the scripts, never in the Makefile.
```bash
make build # cfg/standalone -> gen/standalone
make build sample # cfg/sample -> gen/sample
make start # run it
```
## The targets
| Command | Runs | Does |
| --- | --- | --- |
| `make build [<room>\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` |
| `make start [<room>] [-d]` | `ctrl/start.sh` | run a built room's compose stack |
| `make stop [<room>]` | `ctrl/stop.sh` | stop it |
| `make cluster [up\|down\|status]` | `ctrl/cluster.sh` | the shared kind cluster |
| `make component [list\|publish\|sync\|watch\|diff]` | `ctrl/spr.py` | publish a distributable component |
| `make deploy` | `ctrl/deploy.sh` | rsync `gen/standalone` to the server and restart |
Bare words pass straight through, so `make build sample` becomes
`ctrl/build.sh sample`. Anything starting with a dash would be eaten by make
itself, so those go through `ARGS`:
```bash
make deploy ARGS="--build"
make component ARGS="publish soleprint-ui /tmp/out --dist"
```
## What a build does
`python build.py --cfg <room>` runs these in order:
1. **Clean** `gen/<room>/`. A build is not incremental — a stale file left
behind is worse than a slow build.
2. **Copy the framework.** `main.py`, `run.py`, `index.html`, `Dockerfile`,
`requirements.txt`, `dataloader/`, `common/`, and the three systems
(`artery/`, `atlas/`, `station/`).
3. **Merge the room** (`copy_cfg`). `cfg/<room>/config.json` lands in `cfg/`,
`data/*.json` in `data/`, and anything under `cfg/<room>/soleprint/artery|atlas|station/`
is merged *over* the framework copy — which is how a room adds its own vein,
shunt, tool or generator without forking the tree.
4. **Compose cabinets.** The dependency containers the room declared in
`data/cabinets.json` are merged into its `docker-compose.yml`. See
[Cabinets](#station-cabinets).
5. **Generate models.** modelgen reads the room's `config.json` and writes
`models/pydantic/__init__.py`.
6. **Render k8s** (optional). When the room's config enables it,
`soleprint/ctrl/k8s/` writes manifests and lifecycle scripts.
## What comes out
A standalone room is a flat instance:
```
gen/standalone/
run.py main.py Dockerfile docker-compose.yml
artery/ atlas/ station/ common/
cfg/config.json
data/*.json
models/pydantic/
```
A **managed** room — one that wraps an existing application — is three folders
instead, because soleprint sits beside the app rather than containing it:
```
gen/<room>/
<app>/ the application's repos, plus its ctrl scripts
link/ bridge code between the two
soleprint/ the instance, exactly as above
```
`build.py` picks between them on whether the room's `config.json` has a
`managed` block. `gen/` is gitignored in full: it is an artifact, and the way to
change it is to change `cfg/<room>/` and rebuild.
## Distributing components
Rooms are compiled; *components* are published. `registry.json` lists what can
be shipped out of this repo on its own:
```bash
make component # list
make component ARGS="publish soleprint-ui /tmp/out --dist"
make component ARGS="watch soleprint-ui ../unt/ui/framework"
```
`--dist` copies only the built bundle — `dist/**` plus `package.json`,
`README.md` and `LICENSE` — rather than the source. It refuses to publish an
empty `dist/`, because a component whose bundle was never built is the failure
that shows up later as a container that starts and renders nothing:
```
no build at soleprint/common/ui
build it first: cd soleprint/common/ui && pnpm build
```
Each publish leaves a `.spr` stamp in the destination recording name, version,
type, source and mode, so a copy can say where it came from.
## Deploying
```bash
make deploy ARGS="--build" # rebuild, sync, restart
make deploy ARGS="--sync-only" # sync, leave it running
```
`deploy.sh` rsyncs `gen/standalone/` and runs `docker compose up -d --build` on
the far side. `.env` is excluded, so server secrets stay on the server.
## Running without building
`python run.py` from `soleprint/` serves every subsystem on one port (12000 by
default) straight from the source tree. It is for developing the framework
itself; a room's `cfg/config.json` does not exist there, so the landing pages
fall back to their defaults. Rooms use docker.

View File

@@ -0,0 +1,107 @@
# Cabinets
A cabinet is a **dependency container** a room can switch on: postgres, redis,
airflow. The vocabulary already had the word — `execution.container` in every
room's `config.json` is *"Cabinet — tool container"* — and until now nothing
stood behind it.
The problem it solves: a generated artifact knows what it needs and had no way
to say so. A shunt built from a client's spreadsheets holds its rows in memory
happily, but the moment you want them to survive a restart you need postgres,
and wiring postgres in meant hand-editing a room's `docker-compose.yml` and then
hand-editing the cluster too. A cabinet is that declaration, made once and read
by both paths.
## Switching one on
Add `cfg/<room>/data/cabinets.json`, the same shape as its sibling `data/*.json`
files:
```json
[
{ "name": "postgres" },
{ "name": "redis" },
{ "name": "airflow", "env": { "AIRFLOW_ADMIN_PASSWORD": "change-me" } }
]
```
Then build. The compose merge is a step in [Export / Compile](#export):
```bash
python build.py --cfg sample
cd gen/sample && docker compose up -d
```
`build.py` merges each cabinet's compose fragment into the room's
`docker-compose.yml`, declares its named volumes, and appends its settings to
`.env.example` — never to `.env`.
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
ships its own `db`; switching the postgres cabinet on will not replace it. The
build says so when it skips one:
```
Composing cabinets...
cabinets: redis, airflow
cabinets already declared by the room, left alone: postgres
```
Dependencies come along automatically. Airflow without a metadata database is a
container that exits on boot, so asking for `airflow` brings `postgres` and
`redis` with it, ordered so compose reads them before the thing that needs them.
## On a cluster
Every cabinet names a `rig_addon`. Where a room runs on kind rather than
compose, the same dependency installs as a rig addon of that name:
```bash
cd rig
PROFILE=data make cluster up
PROFILE=data make addons install
kubectl -n data port-forward svc/postgres 5432:5432
kubectl -n data port-forward svc/airflow 8080:8080
```
The two paths are deliberately separate — compose for a laptop, manifests for a
cluster — and `rig_addon` is the thread between them, so the room declares the
dependency once either way. The addons generate their own passwords on first
install and keep them across re-runs, so re-running never rotates a credential
out from under something already connected.
## What ships
| Cabinet | Image | Notes |
| --- | --- | --- |
| `postgres` | `postgres:16-alpine` | healthcheck wired, so `depends_on: service_healthy` works |
| `redis` | `redis:7-alpine` | cache, and the broker for anything queue-shaped |
| `airflow` | `apache/airflow:2.10.4` | one container on `standalone`; needs postgres and redis |
## Writing one
```
soleprint/station/cabinets/<name>/
cabinet.json what it is, what it needs, what it exports
service.yml the compose service, verbatim
```
`cabinet.json`:
| Key | Purpose |
| --- | --- |
| `name` | must match the directory |
| `title`, `description` | shown on the station index |
| `service` | the key to merge under in `services:` (defaults to `name`) |
| `env` | settings and defaults, written to `.env.example` |
| `volumes` | named volumes to declare at the top level |
| `depends_on` | other cabinets that must come with it |
| `rig_addon` | the matching `rig/ctrl/addons/<name>.sh`, if there is one |
| `notes` | lines written into `.env.example` as comments |
`service.yml` is a plain compose fragment with one top-level key — the service
name. It stays YAML rather than being generated from JSON so it reads like the
file it becomes, and so anything compose supports is available without this tool
learning about it first.
Adding a cabinet is adding a directory. Nothing dispatches on the name.

View File

@@ -1,6 +1,8 @@
# Datagen
Test data generator using faker. Produces realistic, domain-specific data for testing and development.
Test data generator. Produces realistic, domain-specific records for testing and
development, from generators a room writes or that
[modelgen](#station-modelgen) writes for it.
**Status:** live
@@ -8,50 +10,95 @@ Test data generator using faker. Produces realistic, domain-specific data for te
## What It Does
Datagen generates fake but realistic data. Names, emails, addresses, transactions -- whatever your domain needs. It uses Python's faker library under the hood.
Datagen hands out instances of a room's models. Core ships the base class, the
discovery, the HTTP API and the browser UI; the generators themselves belong to
a room, because what counts as realistic is a property of the domain.
Core datagen is a placeholder. The real work happens in room-specific generators.
Generation is stdlib `random`, `uuid` and `datetime`**not** faker, which is
not a dependency of this repo.
## Structure
```
soleprint/station/tools/datagen/ # Core (base classes, placeholder)
cfg/<room>/soleprint/station/tools/datagen/ # Room-specific generators
soleprint/station/tools/datagen/ # base class, api, UI
cfg/<room>/soleprint/station/tools/datagen/ # the room's generators
```
After build, both merge into `gen/<room>/station/tools/datagen/`.
## Pattern
## The contract
Rooms subclass a base generator and provide domain-specific data factories:
A generator subclasses `BaseDataGenerator` and defines **one method per model,
named after it**. There is no registration step: the method name *is* the model
name.
```python
from station.tools.datagen.base import BaseGenerator
from station.tools.datagen.base import BaseDataGenerator
class RoomDataGenerator(BaseGenerator):
def generate_customers(self, count=10):
return [self.fake_customer() for _ in range(count)]
class RoomDataGenerator(BaseDataGenerator):
def customer(self, **kwargs):
return {"id": str(uuid4()), "name": ..., "email": ..., **kwargs}
def fake_customer(self):
return {
"name": self.faker.name(),
"email": self.faker.email(),
"phone": self.faker.phone_number(),
}
def invoice(self, customer_id=None, **kwargs):
return {"id": str(uuid4()), "customer_id": customer_id, **kwargs}
```
The base class provides:
| Method | Does |
| --- | --- |
| `generate(model, count=1, **kwargs)` | calls the matching method `count` times; `kwargs` pass through to every call |
| `available_models()` | the method names, which are the model names |
| `schema()` | override to return a graphgen-compatible schema |
Discovery is by convention too: any `*.py` in the datagen directory whose first
class ends in `Generator` is loaded and keyed by its filename.
## Generating a generator
Writing one by hand is optional. modelgen's `datagen` target emits the whole
class from a schema — and when the schema came from spreadsheets, the generated
class **samples the real rows** rather than inventing values:
```bash
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t datagen
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t datagen
```
Each room defines what data it needs. Core provides the faker instance and base class. Rooms provide the factories.
This is how [shuntgen](#station-shuntgen) fills a generated shunt.
## Room Configuration
## HTTP API
Room generators live in `cfg/<room>/soleprint/station/tools/datagen/`. They are fully self-contained -- they define their own models, factories, and output formats.
Mounted under `/station/tools/datagen/`:
The core module provides:
- Base generator class with faker instance
- CLI entry point
- Output formatting (JSON, CSV)
| Route | Returns |
| --- | --- |
| `GET /` | the browser UI |
| `GET /api/generators` | loaded generator files and their models |
| `GET /api/models?generator=` | model names |
| `POST /api/generate` | `{model, count, generator?, kwargs?}` → the records |
| `GET /api/schema?generator=` | the generator's schema, if it exposes one |
## Feeding graphgen
A generator that overrides `schema()` is surfaced at `/api/schema` in the format
[graphgen](#station-graphgen) reads, so the same definition draws the diagram:
```python
def schema(self):
return {
"models": {
"Invoice": {
"doc": "A billed order.",
"fields": {
"id": {"type": "UUID", "pk": True},
"customer_id": {"type": "FK:Customer"},
"total": {"type": "float"},
},
}
}
}
```
Rooms provide:
- Domain-specific generator subclasses
- Field definitions and relationships
- Volume and distribution configuration
`FK:<Model>` and `M2M:<Model>` are how relations are written. modelgen's
`datagen` target emits this method for you.

View File

@@ -1,54 +1,111 @@
# Modelgen
Generates platform-specific models from JSON Schema. Reads schema once, writes models for multiple targets.
Multi-source, multi-target model generator. Reads a schema from wherever it
already lives, and writes it out for every stack that needs it.
**Status:** dev
**Status:** live
---
## What It Does
Modelgen takes a JSON Schema definition and produces model code for different platforms:
Everything passes through one intermediate representation — `ModelDefinition`,
`FieldDefinition`, `EnumDefinition`. **Loaders** fill it, **generators** emit
from it, and the two sides do not know about each other. Adding an input means
one extractor and every output comes with it; adding an output means one
generator and every input already feeds it.
- **Pydantic** -- Python data validation models
- **Django ORM** -- Django model classes
- **Prisma** -- Prisma schema definitions
```
dataclasses ─┐ ┌─ pydantic
Django │ ├─ django
SQLAlchemy ├──▶ ModelDefinition ──▶├─ sqlmodel
a live DB │ FieldDefinition ├─ typescript
OpenAPI │ EnumDefinition ├─ protobuf
CSV/ODS ─┘ ├─ prisma
├─ strawberry
├─ schema (graphgen)
└─ datagen
```
One schema, multiple outputs.
Core is **pure standard library**. It is published as `soleprint-modelgen` and
installs with no dependencies; live-database extraction is an extra
(`pip install "soleprint-modelgen[db]"`), and YAML specs need PyYAML.
## Extractors
## Sources
Modelgen also works in reverse. Extractors read existing codebases and produce a normalized schema representation:
| Command | Reads |
| --- | --- |
| `from-schema` | Python dataclasses in a `schema/` folder |
| `from-config` | a room's `config.json` |
| `extract` | a Django or SQLAlchemy codebase (`--framework auto` detects) |
| `from-db` | a live database, any SQLAlchemy dialect |
| `from-openapi` | an OpenAPI 3.x / Swagger 2.0 document |
| `from-tabular` | a directory of `.csv` / `.tsv` / `.ods` spreadsheets |
- **Django extractor** -- reads Django model files
- **SQLAlchemy extractor** -- reads SQLAlchemy model files
- **Prisma extractor** -- reads Prisma schema files
```bash
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript,schema
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t pydantic,datagen
python -m station.tools.modelgen extract -s /path/to/django -o out/ -t prisma
python -m station.tools.modelgen from-db -u postgresql://… -o out/ -t typescript
python -m station.tools.modelgen list-formats
```
Extractors feed into graphgen for visualization.
### From a spec
## Output
`components.schemas` (or Swagger's `definitions`) become models. `$ref` chains
and `allOf` are resolved, enums are materialised as real `Enum` classes so every
target names them properly, and a referenced object becomes a relation rather
than a nested type — the same call the database extractor makes, and what keeps
the generated code valid for every target.
Generated models are written to `gen/<room>/models/`.
The parse also yields the *operations*, which is what
[shuntgen](#station-shuntgen) turns into routes.
```
gen/<room>/models/
├── pydantic/
├── django/
└── prisma/
```
### From spreadsheets
## CLI
One model per CSV file, one per sheet in an ODS workbook. Column types are
inferred from the values actually present, and a blank cell makes the column
optional. Keys and relations are inferred by name and then confirmed against the
data: an `id` column that is not unique is not treated as a key, and
`customer_id` is only a foreign key if a `customers` sheet came with it.
```bash
python -m modelgen
```
The rows are kept, not just the shape — which is what lets the `datagen` target
sample real values instead of inventing them.
Reads from `schema.json` (the project source of truth) and writes to the configured output directory.
ODS is read with `zipfile` and `ElementTree`. No odfpy, no pandas: the
dependency-free promise is what makes this package publishable on its own.
## Shared Distribution
## Targets
Modelgen is also distributed as a shared component via `ctrl/spr.py`. This allows other projects to use model generation without running full soleprint.
`pydantic`, `django`, `sqlmodel`, `typescript` (`ts`), `protobuf` (`proto`),
`prisma`, `strawberry`, `schema` (`jsonschema`), `datagen`.
## Schema Source
Two are worth calling out:
- **`schema`** writes the graphgen-compatible `schema.json` — the portable
artifact [graphgen](#station-graphgen) and databrowse read directly.
Relations come out as `FK:<Model>` and `M2M:<Model>`.
- **`datagen`** writes a `BaseDataGenerator` subclass for
[datagen](#station-datagen), including its `schema()` override. Given
spreadsheet rows it samples them; otherwise it synthesises from the types.
Multiple targets in one run get one file each, named `models_<target><ext>`.
## In a build
`build.py` calls modelgen during every room build, writing
`gen/<room>/models/pydantic/__init__.py` from the room's `config.json`. See
[Export / Compile](#export).
## Tests
```bash
cd soleprint/station/tools
python -m unittest modelgen.tests.test_extractors
```
The source of truth is `schema.json` at the project root. All model generation starts from this file. Room-specific schema extensions live in `cfg/<room>/models/`.
stdlib `unittest`, no pytest, and every input is built in a temp directory — the
tests have to pass with nothing installed and nothing else in the tree. Run them
from `station/tools/`, not from inside `modelgen/`: the package ships a
`types.py`, and putting its own directory on `sys.path` shadows the standard
library module of that name.

View File

@@ -0,0 +1,129 @@
# Shuntgen
Generates runnable [shunts](#artery-shunts) from the two things people actually
have: a service contract, or a folder of spreadsheets.
Writing a shunt by hand means copying `artery/shunts/example/` and filling in
`responses.json` entry by entry. That is fine for three endpoints and untenable
for eighty — and it is the wrong work anyway, because the endpoints are already
described in the spec somebody handed you.
```bash
# a spec you were handed
python -m station.tools.shuntgen from-openapi -s api.yaml -o artery/shunts/petstore
# sheets a client sent
python -m station.tools.shuntgen from-tabular -s ./sheets -o artery/shunts/books
python -m station.tools.shuntgen list
```
Run from `soleprint/`. Also in the browser at `/station/tools/shuntgen/`, where
you can upload a spec, preview the routes it would serve, and generate.
## What comes out
```
artery/shunts/<name>/
main.py builds the app from the spec
run.py uvicorn entry point (PORT, or depot/config.json)
shunt_runtime.py vendored runtime — no soleprint import
models.py pydantic, via modelgen
datagen_<name>.py BaseDataGenerator subclass, via modelgen
depot/spec.json routes, collections and schema
depot/responses.json pinned overrides — yours, never overwritten
depot/config.json delays, error rate, prefill — yours, never overwritten
depot/data.json imported rows
templates/index.html config UI
README.md
```
```bash
cd artery/shunts/books && python run.py
curl localhost:8098/customers
```
The routes are built at startup from `spec.json` rather than written out as
source. That keeps the generated code short enough to read, and puts the
behaviour in one reviewable place: fixing `runtime.py` fixes every shunt, and
regenerating is a copy.
## Where a response comes from
First hit wins:
1. `depot/responses.json` — a pinned override, keyed `"METHOD /path"`
2. the store — rows imported from sheets, plus anything POSTed since
3. the spec's `example`, if the source document carried one
4. `datagen_<name>.py`, synthesising from the schema
5. `{}`
The store is what makes it behave like a service rather than a random-value
faucet: POST something and GET it back, ask for `/pets/7` and get the pet whose
id is 7. Collections that arrived with no rows are prefilled with generated
ones, so the first call answers with something.
## Two sources, one pipeline
Both inputs are [modelgen](#station-modelgen) extractors, so the same shapes
also generate pydantic, TypeScript, prisma and a
[graphgen](#station-graphgen) schema:
```bash
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t schema,datagen
```
| Source | Becomes | Routes |
| --- | --- | --- |
| OpenAPI 3.x / Swagger 2.0 | one model per schema; enums become real Enums, `$ref` becomes a relation | the operations the document declares |
| `.csv` / `.tsv` / `.ods` | one model per file or sheet, types inferred per column | five CRUD routes per table |
Keys and relations are inferred by name and then **checked against the data**:
an `id` column that is not unique is not treated as a key, and `customer_id` is
only a foreign key if a `customers` sheet came with it.
ODS is read with `zipfile` and `ElementTree` — no odfpy, no pandas — which is
what lets modelgen stay dependency-free and publishable on its own.
## Control endpoints
Every generated shunt serves these:
| Endpoint | Purpose |
| --- | --- |
| `GET /health` | liveness |
| `GET /mock/spec` | the routes it was built from |
| `GET /mock/stats` | call counts and row counts |
| `POST /mock/reset` | restore the imported rows, clear counters |
| `GET,POST /mock/config` | delays, error rate, `unknown_id`, page size |
| `GET,POST /mock/responses` | pin an override; set a key to `null` to drop it |
```bash
# make it slow and flaky, the way the real thing is
curl -X POST localhost:8098/mock/config \
-H 'content-type: application/json' \
-d '{"enable_random_delays": true, "error_rate": 0.2}'
# make one call answer something specific
curl -X POST localhost:8098/mock/responses \
-H 'content-type: application/json' \
-d '{"GET /customers/1": {"id": 1, "name": "PINNED"}}'
```
`unknown_id` decides what an unknown key does: `generate` (the default) invents
a record wearing the id that was asked for; `404` refuses it. Generate by
default, because a client pointed at a fresh shunt should just work — flip it
when the error path is what you are testing.
## Dependency containers
`--cabinet postgres,redis` writes a `cabinet.json` declaring what the shunt
expects. `build.py` composes those services into the room's compose file, and on
a cluster they install as rig addons of the same name. See
[Cabinets](#station-cabinets).
## Regenerating
Everything is overwritten except `depot/responses.json` and `depot/config.json`.
Those two are yours.

View File

@@ -22,8 +22,11 @@
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true},
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true},
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true},
{"id": "station-shuntgen", "title": {"en": "↳ Shuntgen"}, "sub": true},
{"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true},
{"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true},
{"id": "components", "title": {"en": "Shared Components"}},
{"id": "export", "title": {"en": "Export / Compile"}},
{"id": "deployment", "title": {"en": "Deployment"}}
]

67
docs/graphs/README.md Normal file
View File

@@ -0,0 +1,67 @@
# Graphs
The `.dot` files carry **structure and meaning**. The palette lives in
`themes/*.gvpr` and is applied at render time.
```bash
./render.sh # every graph, every theme
./render.sh lucid # one theme
./render.sh dark system_overview
```
Needs graphviz (`sudo apt install graphviz`). The committed SVGs work without
it; this is only needed to re-render.
## Why themes, and why gvpr
The sources used to hardcode the dark palette inline, which meant one look and
no way to get another. `gvpr` rewrites the *parsed* graph, so it overrides
whatever a `.dot` set — one source renders in any theme without being edited.
Command-line `-G`/`-N`/`-E` flags would not do: those are defaults, and an
attribute written in the file beats them.
Colour is **baked into each SVG** rather than driven by CSS, because both docs
sites embed graphs with `<img src=...>`. That makes the SVG a separate document
the page's stylesheet cannot reach.
## Output
| Theme | Writes | For |
| --- | --- | --- |
| `dark` | `<name>.svg` | the docs site — this is the default, and what `docs/data/en/*.md` links to |
| `lucid` | `<name>.lucid.svg` | regulated documents, print, and sitting beside a real lucid.app export |
## Classes
Nodes, edges, clusters and the graph itself may carry a `class`. Anything
untagged gets the theme's neutral treatment.
| Class | Means | dark | lucid |
| --- | --- | --- | --- |
| `accent` | the emphasised thing | amber outline | blue outline, pale blue fill |
| `accent-text` | emphasised *label*, not a box | amber text | blue text |
| `ok` | live, working | green text | green outline, pale green fill |
| `artery` / `atlas` / `station` | belongs to that system | that system's colour | its pale equivalent |
| `muted` | present but not the point | grey text | grey fill |
`class` survives into the SVG (`<g class="node accent">`), so an **inlined** SVG
can also be styled by page CSS. That is not how the docs embed them, but it is
there if a page wants it.
## What themes never touch
- **`shape`** — a cylinder is a datastore, not a decoration.
- **`style=invis`** — layout scaffolding. Filling it would draw it.
- **`style=dashed`** — a weaker relationship; the theme preserves it and adds to it.
- **`label`, `rankdir`, `rank`, `fontsize`** — content and layout.
## Adding a theme
Drop a `themes/<name>.gvpr` in beside the others; `render.sh` picks it up with no
edit. Match the palette to `soleprint/common/theme/themes/<name>.css` so a
diagram and the page around it are the same visual language.
Name fonts that exist on the target. `lucid.gvpr` uses Arial deliberately: it is
on every Windows box and fontconfig aliases it to Liberation Sans on Linux, so
the SVG measures the same on both and text does not reflow out of its box.

View File

@@ -1,43 +1,37 @@
digraph artery_hierarchy {
bgcolor="#0a0a0a"
rankdir=LR
fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#b91c1c"]
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
edge [class="artery" fontname="Helvetica" fontsize=9]
label="Artery — Component Hierarchy"
labelloc=t
fontsize=14
fontcolor="#fca5a5"
vein [label="Vein\nstateless API connector" fillcolor="#1a1a1a"]
pulse [label="Pulse\nVein + Room + Depot" fillcolor="#1a1a1a"]
plexus [label="Plexus\nfull app: backend\n+ frontend + DB" fillcolor="#1a1a1a"]
shunt [label="Shunt\nfake connector\nfor testing" fillcolor="#1a1a1a" color="#d4a574"]
vein [label="Vein\nstateless API connector"]
pulse [label="Pulse\nVein + Room + Depot"]
plexus [label="Plexus\nfull app: backend\n+ frontend + DB"]
shunt [class="accent" label="Shunt\nfake connector\nfor testing"]
vein -> pulse [label="compose"]
pulse -> plexus [label="extend"]
shunt -> vein [label="replaces" style=dashed color="#d4a574" fontcolor="#d4a574"]
shunt -> vein [class="accent" label="replaces" style=dashed]
// Examples
subgraph cluster_examples {
label="Live Veins"
style=dashed
color="#333"
fontcolor="#666"
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9]
google [label="Google" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9]
ia [label="IA" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9]
jira [class="ok" label="Jira" fontsize=9]
google [class="accent-text" label="Google" fontsize=9]
ia [class="ok" label="IA" fontsize=9]
}
subgraph cluster_shunts {
label="Shunts"
style=dashed
color="#333"
fontcolor="#666"
mp [label="MercadoPago" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9]
mp [class="accent-text" label="MercadoPago" fontsize=9]
}
jira -> vein [style=invis]

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: artery_hierarchy Pages: 1 -->
<svg width="776pt" height="292pt"
viewBox="0.00 0.00 776.00 292.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 287.75)">
<title>artery_hierarchy</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-287.75 772.25,-287.75 772.25,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="384.12" y="-266.45" font-family="Arial" font-size="14.00" fill="#1f2933">Artery — Component Hierarchy</text>
<g id="clust1" class="cluster">
<title>cluster_examples</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M143.75,-68C143.75,-68 197.25,-68 197.25,-68 203.25,-68 209.25,-74 209.25,-80 209.25,-80 209.25,-240 209.25,-240 209.25,-246 203.25,-252 197.25,-252 197.25,-252 143.75,-252 143.75,-252 137.75,-252 131.75,-246 131.75,-240 131.75,-240 131.75,-80 131.75,-80 131.75,-74 137.75,-68 143.75,-68"/>
<text xml:space="preserve" text-anchor="middle" x="170.5" y="-234.7" font-family="Arial" font-size="14.00" fill="#616e7c">Live Veins</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_shunts</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M20,-8C20,-8 85.75,-8 85.75,-8 91.75,-8 97.75,-14 97.75,-20 97.75,-20 97.75,-72 97.75,-72 97.75,-78 91.75,-84 85.75,-84 85.75,-84 20,-84 20,-84 14,-84 8,-78 8,-72 8,-72 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="52.88" y="-66.7" font-family="Arial" font-size="14.00" fill="#616e7c">Shunts</text>
</g>
<!-- jira -->
<g id="node1" class="node ok">
<title>jira</title>
<path fill="#e6f5ec" stroke="#1a7f45" d="M185,-112C185,-112 155,-112 155,-112 149,-112 143,-106 143,-100 143,-100 143,-88 143,-88 143,-82 149,-76 155,-76 155,-76 185,-76 185,-76 191,-76 197,-82 197,-88 197,-88 197,-100 197,-100 197,-106 191,-112 185,-112"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-90.7" font-family="Arial" font-size="9.00" fill="#1f2933">Jira</text>
</g>
<!-- vein -->
<g id="node5" class="node">
<title>vein</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M401,-82C401,-82 296.5,-82 296.5,-82 290.5,-82 284.5,-76 284.5,-70 284.5,-70 284.5,-58 284.5,-58 284.5,-52 290.5,-46 296.5,-46 296.5,-46 401,-46 401,-46 407,-46 413,-52 413,-58 413,-58 413,-70 413,-70 413,-76 407,-82 401,-82"/>
<text xml:space="preserve" text-anchor="middle" x="348.75" y="-67.05" font-family="Arial" font-size="11.00" fill="#1f2933">Vein</text>
<text xml:space="preserve" text-anchor="middle" x="348.75" y="-53.55" font-family="Arial" font-size="11.00" fill="#1f2933">stateless API connector</text>
</g>
<!-- jira&#45;&gt;vein -->
<!-- google -->
<g id="node2" class="node accent&#45;text">
<title>google</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M185,-166C185,-166 155,-166 155,-166 149,-166 143,-160 143,-154 143,-154 143,-142 143,-142 143,-136 149,-130 155,-130 155,-130 185,-130 185,-130 191,-130 197,-136 197,-142 197,-142 197,-154 197,-154 197,-160 191,-166 185,-166"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-144.7" font-family="Arial" font-size="9.00" fill="#3a7dff">Google</text>
</g>
<!-- ia -->
<g id="node3" class="node ok">
<title>ia</title>
<path fill="#e6f5ec" stroke="#1a7f45" d="M185,-220C185,-220 155,-220 155,-220 149,-220 143,-214 143,-208 143,-208 143,-196 143,-196 143,-190 149,-184 155,-184 155,-184 185,-184 185,-184 191,-184 197,-190 197,-196 197,-196 197,-208 197,-208 197,-214 191,-220 185,-220"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-198.7" font-family="Arial" font-size="9.00" fill="#1f2933">IA</text>
</g>
<!-- mp -->
<g id="node4" class="node accent&#45;text">
<title>mp</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M77.75,-52C77.75,-52 28,-52 28,-52 22,-52 16,-46 16,-40 16,-40 16,-28 16,-28 16,-22 22,-16 28,-16 28,-16 77.75,-16 77.75,-16 83.75,-16 89.75,-22 89.75,-28 89.75,-28 89.75,-40 89.75,-40 89.75,-46 83.75,-52 77.75,-52"/>
<text xml:space="preserve" text-anchor="middle" x="52.88" y="-30.7" font-family="Arial" font-size="9.00" fill="#3a7dff">MercadoPago</text>
</g>
<!-- shunt -->
<g id="node8" class="node accent">
<title>shunt</title>
<path fill="#d6e4ff" stroke="#3a7dff" d="M201.25,-58.25C201.25,-58.25 138.75,-58.25 138.75,-58.25 132.75,-58.25 126.75,-52.25 126.75,-46.25 126.75,-46.25 126.75,-21.75 126.75,-21.75 126.75,-15.75 132.75,-9.75 138.75,-9.75 138.75,-9.75 201.25,-9.75 201.25,-9.75 207.25,-9.75 213.25,-15.75 213.25,-21.75 213.25,-21.75 213.25,-46.25 213.25,-46.25 213.25,-52.25 207.25,-58.25 201.25,-58.25"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-43.8" font-family="Arial" font-size="11.00" fill="#1f2933">Shunt</text>
<text xml:space="preserve" text-anchor="middle" x="170" y="-30.3" font-family="Arial" font-size="11.00" fill="#1f2933">fake connector</text>
<text xml:space="preserve" text-anchor="middle" x="170" y="-16.8" font-family="Arial" font-size="11.00" fill="#1f2933">for testing</text>
</g>
<!-- mp&#45;&gt;shunt -->
<!-- pulse -->
<g id="node6" class="node">
<title>pulse</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M595.5,-82C595.5,-82 498.5,-82 498.5,-82 492.5,-82 486.5,-76 486.5,-70 486.5,-70 486.5,-58 486.5,-58 486.5,-52 492.5,-46 498.5,-46 498.5,-46 595.5,-46 595.5,-46 601.5,-46 607.5,-52 607.5,-58 607.5,-58 607.5,-70 607.5,-70 607.5,-76 601.5,-82 595.5,-82"/>
<text xml:space="preserve" text-anchor="middle" x="547" y="-67.05" font-family="Arial" font-size="11.00" fill="#1f2933">Pulse</text>
<text xml:space="preserve" text-anchor="middle" x="547" y="-53.55" font-family="Arial" font-size="11.00" fill="#1f2933">Vein + Room + Depot</text>
</g>
<!-- vein&#45;&gt;pulse -->
<g id="edge1" class="edge artery">
<title>vein&#45;&gt;pulse</title>
<path fill="none" stroke="#c0392b" d="M413.47,-64C434.03,-64 456.89,-64 477.76,-64"/>
<polygon fill="#c0392b" stroke="#c0392b" points="477.75,-66.45 484.75,-64 477.75,-61.55 477.75,-66.45"/>
<text xml:space="preserve" text-anchor="middle" x="449.75" y="-65.95" font-family="Arial" font-size="9.00" fill="#c0392b">compose</text>
</g>
<!-- plexus -->
<g id="node7" class="node">
<title>plexus</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M756.25,-88.25C756.25,-88.25 683.25,-88.25 683.25,-88.25 677.25,-88.25 671.25,-82.25 671.25,-76.25 671.25,-76.25 671.25,-51.75 671.25,-51.75 671.25,-45.75 677.25,-39.75 683.25,-39.75 683.25,-39.75 756.25,-39.75 756.25,-39.75 762.25,-39.75 768.25,-45.75 768.25,-51.75 768.25,-51.75 768.25,-76.25 768.25,-76.25 768.25,-82.25 762.25,-88.25 756.25,-88.25"/>
<text xml:space="preserve" text-anchor="middle" x="719.75" y="-73.8" font-family="Arial" font-size="11.00" fill="#1f2933">Plexus</text>
<text xml:space="preserve" text-anchor="middle" x="719.75" y="-60.3" font-family="Arial" font-size="11.00" fill="#1f2933">full app: backend</text>
<text xml:space="preserve" text-anchor="middle" x="719.75" y="-46.8" font-family="Arial" font-size="11.00" fill="#1f2933">+ frontend + DB</text>
</g>
<!-- pulse&#45;&gt;plexus -->
<g id="edge2" class="edge artery">
<title>pulse&#45;&gt;plexus</title>
<path fill="none" stroke="#c0392b" d="M607.72,-64C625.49,-64 644.89,-64 662.44,-64"/>
<polygon fill="#c0392b" stroke="#c0392b" points="662.37,-66.45 669.37,-64 662.37,-61.55 662.37,-66.45"/>
<text xml:space="preserve" text-anchor="middle" x="639.38" y="-65.95" font-family="Arial" font-size="9.00" fill="#c0392b">extend</text>
</g>
<!-- shunt&#45;&gt;vein -->
<g id="edge3" class="edge accent">
<title>shunt&#45;&gt;vein</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M213.34,-41.19C232.09,-44.37 254.68,-48.2 275.94,-51.81"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="275.53,-54.23 282.84,-52.98 276.35,-49.4 275.53,-54.23"/>
<text xml:space="preserve" text-anchor="middle" x="248.88" y="-51.85" font-family="Arial" font-size="9.00" fill="#3a7dff">replaces</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -4,98 +4,98 @@
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: artery_hierarchy Pages: 1 -->
<svg width="845pt" height="317pt"
viewBox="0.00 0.00 845.00 317.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 313.25)">
<svg width="845pt" height="294pt"
viewBox="0.00 0.00 845.00 294.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 290.25)">
<title>artery_hierarchy</title>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-313.25 840.5,-313.25 840.5,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="418.25" y="-291.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Component Hierarchy</text>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-290.25 840.5,-290.25 840.5,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="418.25" y="-268.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Artery — Component Hierarchy</text>
<g id="clust1" class="cluster">
<title>cluster_examples</title>
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="135.88,-8 135.88,-193 220.88,-193 220.88,-8 135.88,-8"/>
<text xml:space="preserve" text-anchor="middle" x="178.38" y="-175.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text>
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="135.88,-68 135.88,-253 220.88,-253 220.88,-68 135.88,-68"/>
<text xml:space="preserve" text-anchor="middle" x="178.38" y="-235.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_shunts</title>
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-199 8,-276 100,-276 100,-199 8,-199"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-258.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text>
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-8 8,-85 100,-85 100,-8 8,-8"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-67.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text>
</g>
<!-- jira -->
<g id="node1" class="node ok">
<title>jira</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-112 150.88,-112 150.88,-76 204.88,-76 204.88,-112"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-91.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">Jira</text>
</g>
<!-- vein -->
<g id="node1" class="node">
<g id="node5" class="node">
<title>vein</title>
<polygon fill="#1a1a1a" stroke="#333333" points="446,-167 300.25,-167 300.25,-131 446,-131 446,-167"/>
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-152.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein</text>
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</text>
<polygon fill="#1a1a1a" stroke="#333333" points="446,-82 300.25,-82 300.25,-46 446,-46 446,-82"/>
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-67.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein</text>
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-53.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</text>
</g>
<!-- jira&#45;&gt;vein -->
<!-- google -->
<g id="node2" class="node accent&#45;text">
<title>google</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-166 150.88,-166 150.88,-130 204.88,-130 204.88,-166"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-145.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">Google</text>
</g>
<!-- ia -->
<g id="node3" class="node ok">
<title>ia</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-220 150.88,-220 150.88,-184 204.88,-184 204.88,-220"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-199.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">IA</text>
</g>
<!-- mp -->
<g id="node4" class="node accent&#45;text">
<title>mp</title>
<polygon fill="#1a1a1a" stroke="#333333" points="92,-52 16,-52 16,-16 92,-16 92,-52"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-31.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">MercadoPago</text>
</g>
<!-- shunt -->
<g id="node8" class="node accent">
<title>shunt</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="226.75,-58.25 129,-58.25 129,-9.75 226.75,-9.75 226.75,-58.25"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-43.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Shunt</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">fake connector</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-16.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">for testing</text>
</g>
<!-- mp&#45;&gt;shunt -->
<!-- pulse -->
<g id="node2" class="node">
<g id="node6" class="node">
<title>pulse</title>
<polygon fill="#1a1a1a" stroke="#333333" points="659.25,-167 522.5,-167 522.5,-131 659.25,-131 659.25,-167"/>
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-152.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Pulse</text>
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</text>
<polygon fill="#1a1a1a" stroke="#333333" points="659.25,-82 522.5,-82 522.5,-46 659.25,-46 659.25,-82"/>
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-67.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Pulse</text>
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-53.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</text>
</g>
<!-- vein&#45;&gt;pulse -->
<g id="edge1" class="edge">
<g id="edge1" class="edge artery">
<title>vein&#45;&gt;pulse</title>
<path fill="none" stroke="#b91c1c" d="M446.27,-149C467.03,-149 489.79,-149 510.95,-149"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="510.67,-152.5 520.67,-149 510.67,-145.5 510.67,-152.5"/>
<text xml:space="preserve" text-anchor="middle" x="484.25" y="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">compose</text>
<path fill="none" stroke="#b91c1c" d="M446.27,-64C467.03,-64 489.79,-64 510.95,-64"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="510.67,-67.5 520.67,-64 510.67,-60.5 510.67,-67.5"/>
<text xml:space="preserve" text-anchor="middle" x="484.25" y="-66.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">compose</text>
</g>
<!-- plexus -->
<g id="node3" class="node">
<g id="node7" class="node">
<title>plexus</title>
<polygon fill="#1a1a1a" stroke="#333333" points="836.5,-173.25 726.75,-173.25 726.75,-124.75 836.5,-124.75 836.5,-173.25"/>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-158.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Plexus</text>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-145.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">full app: backend</text>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-131.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</text>
<polygon fill="#1a1a1a" stroke="#333333" points="836.5,-88.25 726.75,-88.25 726.75,-39.75 836.5,-39.75 836.5,-88.25"/>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-73.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Plexus</text>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-60.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">full app: backend</text>
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-46.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</text>
</g>
<!-- pulse&#45;&gt;plexus -->
<g id="edge2" class="edge">
<g id="edge2" class="edge artery">
<title>pulse&#45;&gt;plexus</title>
<path fill="none" stroke="#b91c1c" d="M659.48,-149C677.62,-149 697.19,-149 715.21,-149"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="714.98,-152.5 724.98,-149 714.98,-145.5 714.98,-152.5"/>
<text xml:space="preserve" text-anchor="middle" x="693" y="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">extend</text>
</g>
<!-- shunt -->
<g id="node4" class="node">
<title>shunt</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="226.75,-249.25 129,-249.25 129,-200.75 226.75,-200.75 226.75,-249.25"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-234.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Shunt</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-221.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">fake connector</text>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-207.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">for testing</text>
<path fill="none" stroke="#b91c1c" d="M659.48,-64C677.62,-64 697.19,-64 715.21,-64"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="714.98,-67.5 724.98,-64 714.98,-60.5 714.98,-67.5"/>
<text xml:space="preserve" text-anchor="middle" x="693" y="-66.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">extend</text>
</g>
<!-- shunt&#45;&gt;vein -->
<g id="edge3" class="edge">
<g id="edge3" class="edge accent">
<title>shunt&#45;&gt;vein</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M227.14,-206.02C253.71,-195.57 286.85,-182.54 314.73,-171.57"/>
<polygon fill="#d4a574" stroke="#d4a574" points="315.71,-174.95 323.73,-168.03 313.14,-168.43 315.71,-174.95"/>
<text xml:space="preserve" text-anchor="middle" x="263.5" y="-200.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text>
</g>
<!-- jira -->
<g id="node5" class="node">
<title>jira</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-52 150.88,-52 150.88,-16 204.88,-16 204.88,-52"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-31.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#15803d">Jira</text>
</g>
<!-- jira&#45;&gt;vein -->
<!-- google -->
<g id="node6" class="node">
<title>google</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-106 150.88,-106 150.88,-70 204.88,-70 204.88,-106"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-85.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">Google</text>
</g>
<!-- ia -->
<g id="node7" class="node">
<title>ia</title>
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-160 150.88,-160 150.88,-124 204.88,-124 204.88,-160"/>
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-139.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#15803d">IA</text>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M227.14,-41.49C245.81,-44.39 267.72,-47.79 288.78,-51.06"/>
<polygon fill="#d4a574" stroke="#d4a574" points="288.11,-54.5 298.53,-52.58 289.18,-47.58 288.11,-54.5"/>
<text xml:space="preserve" text-anchor="middle" x="263.5" y="-52.6" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text>
</g>
<!-- mp -->
<g id="node8" class="node">
<title>mp</title>
<polygon fill="#1a1a1a" stroke="#333333" points="92,-243 16,-243 16,-207 92,-207 92,-243"/>
<text xml:space="preserve" text-anchor="middle" x="54" y="-222.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">MercadoPago</text>
</g>
<!-- mp&#45;&gt;shunt -->
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

@@ -1,43 +1,37 @@
digraph cfg_gen_flow {
bgcolor="#0a0a0a"
rankdir=LR
fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#d4a574"]
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
edge [class="accent" fontname="Helvetica" fontsize=9]
label="Build Flow — cfg/ to gen/"
labelloc=t
fontsize=14
fontcolor="#d4a574"
// Source
subgraph cluster_source {
label="Source (committed)"
style=dashed
color="#333"
fontcolor="#666"
core [label="soleprint/\ncore framework" fillcolor="#1a1a1a"]
cfg [label="cfg/<room>/\nroom config" fillcolor="#1a1a1a"]
core [label="soleprint/\ncore framework"]
cfg [label="cfg/<room>/\nroom config"]
}
// Build
build [label="build.py\n--cfg <room>" fillcolor="#1a1a1a" color="#d4a574" shape=component]
build [class="accent" label="build.py\n--cfg <room>" shape=component]
// Output
subgraph cluster_output {
label="Output (generated, gitignored)"
style=dashed
color="#333"
fontcolor="#666"
gen_spr [label="gen/<room>/soleprint/\ncore + room merged" fillcolor="#1a1a1a"]
gen_app [label="gen/<room>/<app>/\ncloned repos" fillcolor="#1a1a1a"]
gen_link [label="gen/<room>/link/\nDB bridge" fillcolor="#1a1a1a"]
gen_spr [label="gen/<room>/soleprint/\ncore + room merged"]
gen_app [label="gen/<room>/<app>/\ncloned repos"]
gen_link [label="gen/<room>/link/\nDB bridge"]
}
// Run
docker [label="docker compose up" fillcolor="#1a1a1a" shape=component]
docker [label="docker compose up" shape=component]
// Flow
core -> build

View File

@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: cfg_gen_flow Pages: 1 -->
<svg width="661pt" height="232pt"
viewBox="0.00 0.00 661.00 232.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 227.75)">
<title>cfg_gen_flow</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-227.75 657.25,-227.75 657.25,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="326.62" y="-206.45" font-family="Arial" font-size="14.00" fill="#1f2933">Build Flow — cfg/ to gen/</text>
<g id="clust1" class="cluster">
<title>cluster_source</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M12,-62C12,-62 123.25,-62 123.25,-62 129.25,-62 135.25,-68 135.25,-74 135.25,-74 135.25,-180 135.25,-180 135.25,-186 129.25,-192 123.25,-192 123.25,-192 12,-192 12,-192 6,-192 0,-186 0,-180 0,-180 0,-74 0,-74 0,-68 6,-62 12,-62"/>
<text xml:space="preserve" text-anchor="middle" x="67.62" y="-174.7" font-family="Arial" font-size="14.00" fill="#616e7c">Source (committed)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_output</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M329,-8C329,-8 502.5,-8 502.5,-8 508.5,-8 514.5,-14 514.5,-20 514.5,-20 514.5,-180 514.5,-180 514.5,-186 508.5,-192 502.5,-192 502.5,-192 329,-192 329,-192 323,-192 317,-186 317,-180 317,-180 317,-20 317,-20 317,-14 323,-8 329,-8"/>
<text xml:space="preserve" text-anchor="middle" x="415.75" y="-174.7" font-family="Arial" font-size="14.00" fill="#616e7c">Output (generated, gitignored)</text>
</g>
<!-- core -->
<g id="node1" class="node">
<title>core</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M100.62,-160C100.62,-160 33.62,-160 33.62,-160 27.62,-160 21.62,-154 21.62,-148 21.62,-148 21.62,-136 21.62,-136 21.62,-130 27.62,-124 33.62,-124 33.62,-124 100.62,-124 100.62,-124 106.62,-124 112.62,-130 112.62,-136 112.62,-136 112.62,-148 112.62,-148 112.62,-154 106.62,-160 100.62,-160"/>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-145.05" font-family="Arial" font-size="11.00" fill="#1f2933">soleprint/</text>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-131.55" font-family="Arial" font-size="11.00" fill="#1f2933">core framework</text>
</g>
<!-- build -->
<g id="node6" class="node accent">
<title>build</title>
<polygon fill="#d6e4ff" stroke="#3a7dff" points="243.25,-106 164.25,-106 164.25,-102 160.25,-102 160.25,-98 164.25,-98 164.25,-78 160.25,-78 160.25,-74 164.25,-74 164.25,-70 243.25,-70 243.25,-106"/>
<polyline fill="none" stroke="#3a7dff" points="164.25,-102 168.25,-102 168.25,-98 164.25,-98"/>
<polyline fill="none" stroke="#3a7dff" points="164.25,-78 168.25,-78 168.25,-74 164.25,-74"/>
<text xml:space="preserve" text-anchor="middle" x="203.75" y="-91.05" font-family="Arial" font-size="11.00" fill="#1f2933">build.py</text>
<text xml:space="preserve" text-anchor="middle" x="203.75" y="-77.55" font-family="Arial" font-size="11.00" fill="#1f2933">&#45;&#45;cfg &lt;room&gt;</text>
</g>
<!-- core&#45;&gt;build -->
<g id="edge1" class="edge accent">
<title>core&#45;&gt;build</title>
<path fill="none" stroke="#3a7dff" d="M112.95,-124.02C126.89,-118.43 142.29,-112.25 156.33,-106.62"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="156.99,-108.99 162.58,-104.11 155.17,-104.45 156.99,-108.99"/>
</g>
<!-- cfg -->
<g id="node2" class="node">
<title>cfg</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M92.38,-106C92.38,-106 41.88,-106 41.88,-106 35.88,-106 29.88,-100 29.88,-94 29.88,-94 29.88,-82 29.88,-82 29.88,-76 35.88,-70 41.88,-70 41.88,-70 92.38,-70 92.38,-70 98.38,-70 104.38,-76 104.38,-82 104.38,-82 104.38,-94 104.38,-94 104.38,-100 98.38,-106 92.38,-106"/>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-91.05" font-family="Arial" font-size="11.00" fill="#1f2933">cfg/&lt;room&gt;/</text>
<text xml:space="preserve" text-anchor="middle" x="67.12" y="-77.55" font-family="Arial" font-size="11.00" fill="#1f2933">room config</text>
</g>
<!-- cfg&#45;&gt;build -->
<g id="edge2" class="edge accent">
<title>cfg&#45;&gt;build</title>
<path fill="none" stroke="#3a7dff" d="M104.56,-88C120.35,-88 139.04,-88 155.85,-88"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="155.42,-90.45 162.42,-88 155.42,-85.55 155.42,-90.45"/>
</g>
<!-- gen_spr -->
<g id="node3" class="node">
<title>gen_spr</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M464.12,-160C464.12,-160 366.38,-160 366.38,-160 360.38,-160 354.38,-154 354.38,-148 354.38,-148 354.38,-136 354.38,-136 354.38,-130 360.38,-124 366.38,-124 366.38,-124 464.12,-124 464.12,-124 470.12,-124 476.12,-130 476.12,-136 476.12,-136 476.12,-148 476.12,-148 476.12,-154 470.12,-160 464.12,-160"/>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-145.05" font-family="Arial" font-size="11.00" fill="#1f2933">gen/&lt;room&gt;/soleprint/</text>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-131.55" font-family="Arial" font-size="11.00" fill="#1f2933">core + room merged</text>
</g>
<!-- docker -->
<g id="node7" class="node">
<title>docker</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="653.25,-160 543.5,-160 543.5,-156 539.5,-156 539.5,-152 543.5,-152 543.5,-132 539.5,-132 539.5,-128 543.5,-128 543.5,-124 653.25,-124 653.25,-160"/>
<polyline fill="none" stroke="#9aa5b1" points="543.5,-156 547.5,-156 547.5,-152 543.5,-152"/>
<polyline fill="none" stroke="#9aa5b1" points="543.5,-132 547.5,-132 547.5,-128 543.5,-128"/>
<text xml:space="preserve" text-anchor="middle" x="598.38" y="-138.3" font-family="Arial" font-size="11.00" fill="#1f2933">docker compose up</text>
</g>
<!-- gen_spr&#45;&gt;docker -->
<g id="edge6" class="edge accent">
<title>gen_spr&#45;&gt;docker</title>
<path fill="none" stroke="#3a7dff" d="M476.56,-142C495.21,-142 515.8,-142 534.61,-142"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="534.6,-144.45 541.6,-142 534.6,-139.55 534.6,-144.45"/>
</g>
<!-- gen_app -->
<g id="node4" class="node">
<title>gen_app</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M459.62,-106C459.62,-106 370.88,-106 370.88,-106 364.88,-106 358.88,-100 358.88,-94 358.88,-94 358.88,-82 358.88,-82 358.88,-76 364.88,-70 370.88,-70 370.88,-70 459.62,-70 459.62,-70 465.62,-70 471.62,-76 471.62,-82 471.62,-82 471.62,-94 471.62,-94 471.62,-100 465.62,-106 459.62,-106"/>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-91.05" font-family="Arial" font-size="11.00" fill="#1f2933">gen/&lt;room&gt;/&lt;app&gt;/</text>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-77.55" font-family="Arial" font-size="11.00" fill="#1f2933">cloned repos</text>
</g>
<!-- gen_link -->
<g id="node5" class="node">
<title>gen_link</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M451.75,-52C451.75,-52 378.75,-52 378.75,-52 372.75,-52 366.75,-46 366.75,-40 366.75,-40 366.75,-28 366.75,-28 366.75,-22 372.75,-16 378.75,-16 378.75,-16 451.75,-16 451.75,-16 457.75,-16 463.75,-22 463.75,-28 463.75,-28 463.75,-40 463.75,-40 463.75,-46 457.75,-52 451.75,-52"/>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">gen/&lt;room&gt;/link/</text>
<text xml:space="preserve" text-anchor="middle" x="415.25" y="-23.55" font-family="Arial" font-size="11.00" fill="#1f2933">DB bridge</text>
</g>
<!-- build&#45;&gt;gen_spr -->
<g id="edge3" class="edge accent">
<title>build&#45;&gt;gen_spr</title>
<path fill="none" stroke="#3a7dff" d="M243.64,-98.38C249.54,-99.94 255.55,-101.52 261.25,-103 288.9,-110.19 319.39,-117.99 345.83,-124.71"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="345.05,-127.04 352.43,-126.39 346.25,-122.29 345.05,-127.04"/>
</g>
<!-- build&#45;&gt;gen_app -->
<g id="edge4" class="edge accent">
<title>build&#45;&gt;gen_app</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M243.66,-88C273.57,-88 315.52,-88 350.33,-88"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="350.04,-90.45 357.04,-88 350.04,-85.55 350.04,-90.45"/>
<text xml:space="preserve" text-anchor="middle" x="284.12" y="-89.95" font-family="Arial" font-size="9.00" fill="#3a7dff">if managed</text>
</g>
<!-- build&#45;&gt;gen_link -->
<g id="edge5" class="edge accent">
<title>build&#45;&gt;gen_link</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M243.66,-77.97C275.9,-69.66 322.12,-57.75 358.32,-48.42"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="358.72,-50.84 364.88,-46.72 357.49,-46.1 358.72,-50.84"/>
<text xml:space="preserve" text-anchor="middle" x="284.12" y="-73.99" font-family="Arial" font-size="9.00" fill="#3a7dff">if managed</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -28,7 +28,7 @@
<text xml:space="preserve" text-anchor="middle" x="75.75" y="-131.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">core framework</text>
</g>
<!-- build -->
<g id="node3" class="node">
<g id="node6" class="node accent">
<title>build</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="271.75,-106 181.5,-106 181.5,-102 177.5,-102 177.5,-98 181.5,-98 181.5,-78 177.5,-78 177.5,-74 181.5,-74 181.5,-70 271.75,-70 271.75,-106"/>
<polyline fill="none" stroke="#d4a574" points="181.5,-102 185.5,-102 185.5,-98 181.5,-98"/>
@@ -37,7 +37,7 @@
<text xml:space="preserve" text-anchor="middle" x="226.62" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">&#45;&#45;cfg &lt;room&gt;</text>
</g>
<!-- core&#45;&gt;build -->
<g id="edge1" class="edge">
<g id="edge1" class="edge accent">
<title>core&#45;&gt;build</title>
<path fill="none" stroke="#d4a574" d="M127.3,-124.07C135.72,-121.06 144.36,-117.95 152.5,-115 158.3,-112.89 164.36,-110.68 170.4,-108.47"/>
<polygon fill="#d4a574" stroke="#d4a574" points="171.59,-111.76 179.76,-105.02 169.17,-105.19 171.59,-111.76"/>
@@ -50,65 +50,65 @@
<text xml:space="preserve" text-anchor="middle" x="75.75" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">room config</text>
</g>
<!-- cfg&#45;&gt;build -->
<g id="edge2" class="edge">
<g id="edge2" class="edge accent">
<title>cfg&#45;&gt;build</title>
<path fill="none" stroke="#d4a574" d="M119.41,-88C135.12,-88 153.15,-88 169.84,-88"/>
<polygon fill="#d4a574" stroke="#d4a574" points="169.63,-91.5 179.63,-88 169.63,-84.5 169.63,-91.5"/>
</g>
<!-- gen_spr -->
<g id="node4" class="node">
<g id="node3" class="node">
<title>gen_spr</title>
<polygon fill="#1a1a1a" stroke="#333333" points="536.5,-160 394.5,-160 394.5,-124 536.5,-124 536.5,-160"/>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-145.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/soleprint/</text>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-131.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">core + room merged</text>
</g>
<!-- build&#45;&gt;gen_spr -->
<g id="edge3" class="edge">
<title>build&#45;&gt;gen_spr</title>
<path fill="none" stroke="#d4a574" d="M272.06,-98.83C278,-100.25 284.02,-101.67 289.75,-103 320.22,-110.08 353.74,-117.62 383.21,-124.16"/>
<polygon fill="#d4a574" stroke="#d4a574" points="382.29,-127.55 392.81,-126.29 383.8,-120.71 382.29,-127.55"/>
<!-- docker -->
<g id="node7" class="node">
<title>docker</title>
<polygon fill="#1a1a1a" stroke="#333333" points="733.5,-160 610.25,-160 610.25,-156 606.25,-156 606.25,-152 610.25,-152 610.25,-132 606.25,-132 606.25,-128 610.25,-128 610.25,-124 733.5,-124 733.5,-160"/>
<polyline fill="none" stroke="#333333" points="610.25,-156 614.25,-156 614.25,-152 610.25,-152"/>
<polyline fill="none" stroke="#333333" points="610.25,-132 614.25,-132 614.25,-128 610.25,-128"/>
<text xml:space="preserve" text-anchor="middle" x="671.88" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">docker compose up</text>
</g>
<!-- gen_spr&#45;&gt;docker -->
<g id="edge6" class="edge accent">
<title>gen_spr&#45;&gt;docker</title>
<path fill="none" stroke="#d4a574" d="M536.83,-142C556.81,-142 578.59,-142 598.68,-142"/>
<polygon fill="#d4a574" stroke="#d4a574" points="598.44,-145.5 608.44,-142 598.44,-138.5 598.44,-145.5"/>
</g>
<!-- gen_app -->
<g id="node5" class="node">
<g id="node4" class="node">
<title>gen_app</title>
<polygon fill="#1a1a1a" stroke="#333333" points="531.62,-106 399.38,-106 399.38,-70 531.62,-70 531.62,-106"/>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-91.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/&lt;app&gt;/</text>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">cloned repos</text>
</g>
<!-- build&#45;&gt;gen_app -->
<g id="edge4" class="edge">
<title>build&#45;&gt;gen_app</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.18,-88C304.75,-88 349.72,-88 388,-88"/>
<polygon fill="#d4a574" stroke="#d4a574" points="387.6,-91.5 397.6,-88 387.6,-84.5 387.6,-91.5"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-90.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
</g>
<!-- gen_link -->
<g id="node6" class="node">
<g id="node5" class="node">
<title>gen_link</title>
<polygon fill="#1a1a1a" stroke="#333333" points="521.88,-52 409.12,-52 409.12,-16 521.88,-16 521.88,-52"/>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/&lt;room&gt;/link/</text>
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-23.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">DB bridge</text>
</g>
<!-- build&#45;&gt;gen_spr -->
<g id="edge3" class="edge accent">
<title>build&#45;&gt;gen_spr</title>
<path fill="none" stroke="#d4a574" d="M272.06,-98.83C278,-100.25 284.02,-101.67 289.75,-103 320.22,-110.08 353.74,-117.62 383.21,-124.16"/>
<polygon fill="#d4a574" stroke="#d4a574" points="382.29,-127.55 392.81,-126.29 383.8,-120.71 382.29,-127.55"/>
</g>
<!-- build&#45;&gt;gen_app -->
<g id="edge4" class="edge accent">
<title>build&#45;&gt;gen_app</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.18,-88C304.75,-88 349.72,-88 388,-88"/>
<polygon fill="#d4a574" stroke="#d4a574" points="387.6,-91.5 397.6,-88 387.6,-84.5 387.6,-91.5"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-90.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">if managed</text>
</g>
<!-- build&#45;&gt;gen_link -->
<g id="edge5" class="edge">
<g id="edge5" class="edge accent">
<title>build&#45;&gt;gen_link</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M272.05,-76.97C278,-75.53 284.02,-74.09 289.75,-72.75 325.35,-64.44 365.13,-55.58 397.73,-48.44"/>
<polygon fill="#d4a574" stroke="#d4a574" points="398.44,-51.86 407.46,-46.31 396.95,-45.03 398.44,-51.86"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-75.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
</g>
<!-- docker -->
<g id="node7" class="node">
<title>docker</title>
<polygon fill="#1a1a1a" stroke="#333333" points="733.5,-160 610.25,-160 610.25,-156 606.25,-156 606.25,-152 610.25,-152 610.25,-132 606.25,-132 606.25,-128 610.25,-128 610.25,-124 733.5,-124 733.5,-160"/>
<polyline fill="none" stroke="#333333" points="610.25,-156 614.25,-156 614.25,-152 610.25,-152"/>
<polyline fill="none" stroke="#333333" points="610.25,-132 614.25,-132 614.25,-128 610.25,-128"/>
<text xml:space="preserve" text-anchor="middle" x="671.88" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">docker compose up</text>
</g>
<!-- gen_spr&#45;&gt;docker -->
<g id="edge6" class="edge">
<title>gen_spr&#45;&gt;docker</title>
<path fill="none" stroke="#d4a574" d="M536.83,-142C556.81,-142 578.59,-142 598.68,-142"/>
<polygon fill="#d4a574" stroke="#d4a574" points="598.44,-145.5 608.44,-142 598.44,-138.5 598.44,-145.5"/>
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-75.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">if managed</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

69
docs/graphs/render.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Render every .dot through every theme.
#
# ./render.sh # all graphs, all themes
# ./render.sh lucid # one theme
# ./render.sh dark system_overview
#
# The sources carry structure and meaning; themes/*.gvpr carry palette. gvpr
# rewrites the parsed graph, so it wins over anything a .dot sets inline — which
# is what lets one source render in several looks without being edited.
#
# Output naming: the default theme writes <name>.svg, because that is what
# docs/data/en/*.md already links to and those links should keep working. Every
# other theme writes <name>.<theme>.svg.
#
# Why baked rather than CSS: both docs sites embed graphs with <img src=...>,
# which makes the SVG a separate document that the page's stylesheet cannot
# reach. Colour has to be in the file.
set -euo pipefail
cd "$(dirname "$0")"
DEFAULT_THEME=dark
if ! command -v dot >/dev/null 2>&1 || ! command -v gvpr >/dev/null 2>&1; then
echo "graphviz not found — install with: sudo apt install graphviz" >&2
echo "(the committed .svg files already work; this is only needed to re-render)" >&2
exit 1
fi
theme_arg="${1:-}"
graph_arg="${2:-}"
themes=()
if [ -n "$theme_arg" ]; then
if [ ! -f "themes/${theme_arg}.gvpr" ]; then
echo "no such theme: themes/${theme_arg}.gvpr" >&2
echo "available: $(ls themes/*.gvpr 2>/dev/null | xargs -n1 basename | sed 's/\.gvpr$//' | tr '\n' ' ')" >&2
exit 1
fi
themes=("$theme_arg")
else
for t in themes/*.gvpr; do themes+=("$(basename "$t" .gvpr)"); done
fi
shopt -s nullglob
sources=(*.dot)
if [ -n "$graph_arg" ]; then
sources=("${graph_arg%.dot}.dot")
[ -f "${sources[0]}" ] || { echo "no such graph: ${sources[0]}" >&2; exit 1; }
fi
[ ${#sources[@]} -gt 0 ] || { echo "no .dot files here"; exit 0; }
for theme in "${themes[@]}"; do
for src in "${sources[@]}"; do
base="${src%.dot}"
if [ "$theme" = "$DEFAULT_THEME" ]; then
out="${base}.svg"
else
out="${base}.${theme}.svg"
fi
# One pipeline, so a gvpr failure fails the whole render rather than
# silently writing a half-themed file.
gvpr -c -f "themes/${theme}.gvpr" "$src" | dot -Tsvg -o "$out"
printf " %-14s %s\n" "$theme" "$out"
done
done
echo
echo "done — ${#sources[@]} graph(s) x ${#themes[@]} theme(s)"

View File

@@ -1,22 +1,20 @@
digraph room_layers {
bgcolor="#0a0a0a"
rankdir=TB
fontname="Helvetica"
node [fontname="Helvetica" fontsize=10 style=filled color="#333" fontcolor="#e5e5e5" shape=record]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
node [fontname="Helvetica" fontsize=10 style=filled shape=record]
edge [fontname="Helvetica" fontsize=9]
label="Room Layers — init wizard"
labelloc=t
fontsize=14
fontcolor="#d4a574"
l0 [label="{Layer 0 | Config + Data | config.json · data/*.json}" fillcolor="#1a1a1a" color="#d4a574"]
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}" fillcolor="#1a1a1a"]
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}" fillcolor="#1a1a1a"]
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}" fillcolor="#1a1a1a"]
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}" fillcolor="#1a1a1a"]
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}" fillcolor="#1a1a1a"]
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}" fillcolor="#1a1a1a"]
l0 [class="accent" label="{Layer 0 | Config + Data | config.json · data/*.json}"]
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}"]
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}"]
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}"]
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}"]
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}"]
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}"]
l0 -> l1 [label="required"]
l1 -> l2 [label="if managed"]
@@ -26,6 +24,6 @@ digraph room_layers {
l5 -> l6 [label="if frontend"]
// Annotations
note_req [label="every room" fillcolor="#0a0a0a" fontcolor="#d4a574" color="#0a0a0a" shape=plaintext fontsize=9]
note_req [class="accent" label="every room" shape=plaintext fontsize=9]
note_req -> l0 [style=invis]
}

View File

@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: room_layers Pages: 1 -->
<svg width="423pt" height="596pt"
viewBox="0.00 0.00 423.00 596.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 591.75)">
<title>room_layers</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-591.75 418.62,-591.75 418.62,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="207.31" y="-570.45" font-family="Arial" font-size="14.00" fill="#1f2933">Room Layers — init wizard</text>
<!-- l0 -->
<g id="node1" class="node accent">
<title>l0</title>
<polygon fill="#d6e4ff" stroke="#3a7dff" points="143.12,-430.5 143.12,-490.5 261.88,-490.5 261.88,-430.5 143.12,-430.5"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-477" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 0</text>
<polyline fill="none" stroke="#3a7dff" points="143.12,-470.5 261.88,-470.5"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-457" font-family="Arial" font-size="10.00" fill="#1f2933">Config + Data</text>
<polyline fill="none" stroke="#3a7dff" points="143.12,-450.5 261.88,-450.5"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-437" font-family="Arial" font-size="10.00" fill="#1f2933">config.json · data/*.json</text>
</g>
<!-- l1 -->
<g id="node2" class="node">
<title>l1</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="115.38,-323 115.38,-383 289.62,-383 289.62,-323 115.38,-323"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-369.5" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 1</text>
<polyline fill="none" stroke="#9aa5b1" points="115.38,-363 289.62,-363"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-349.5" font-family="Arial" font-size="10.00" fill="#1f2933">Docker</text>
<polyline fill="none" stroke="#9aa5b1" points="115.38,-343 289.62,-343"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-329.5" font-family="Arial" font-size="10.00" fill="#1f2933">soleprint/docker&#45;compose.yml · .env</text>
</g>
<!-- l0&#45;&gt;l1 -->
<g id="edge1" class="edge">
<title>l0&#45;&gt;l1</title>
<path fill="none" stroke="#9aa5b1" d="M202.5,-430.11C202.5,-418.22 202.5,-404.35 202.5,-391.73"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="204.95,-391.74 202.5,-384.74 200.05,-391.74 204.95,-391.74"/>
<text xml:space="preserve" text-anchor="middle" x="219.75" y="-403.45" font-family="Arial" font-size="9.00" fill="#616e7c">required</text>
</g>
<!-- l2 -->
<g id="node3" class="node">
<title>l2</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="0,-215.5 0,-275.5 193,-275.5 193,-215.5 0,-215.5"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-262" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 2</text>
<polyline fill="none" stroke="#9aa5b1" points="0,-255.5 193,-255.5"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-242" font-family="Arial" font-size="10.00" fill="#1f2933">Managed App</text>
<polyline fill="none" stroke="#9aa5b1" points="0,-235.5 193,-235.5"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-222" font-family="Arial" font-size="10.00" fill="#1f2933">docker&#45;compose.yml · Dockerfiles · .env</text>
</g>
<!-- l1&#45;&gt;l2 -->
<g id="edge2" class="edge">
<title>l1&#45;&gt;l2</title>
<path fill="none" stroke="#9aa5b1" d="M172.97,-322.61C160.22,-309.92 145.2,-294.97 131.86,-281.7"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="133.63,-280 126.94,-276.8 130.17,-283.47 133.63,-280"/>
<text xml:space="preserve" text-anchor="middle" x="175.55" y="-295.95" font-family="Arial" font-size="9.00" fill="#616e7c">if managed</text>
</g>
<!-- l4 -->
<g id="node4" class="node">
<title>l4</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="211.12,-215.5 211.12,-275.5 407.88,-275.5 407.88,-215.5 211.12,-215.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-262" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 4</text>
<polyline fill="none" stroke="#9aa5b1" points="211.12,-255.5 407.88,-255.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-242" font-family="Arial" font-size="10.00" fill="#1f2933">Scripts</text>
<polyline fill="none" stroke="#9aa5b1" points="211.12,-235.5 407.88,-235.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-222" font-family="Arial" font-size="10.00" fill="#1f2933">ctrl/start.sh · stop.sh · status.sh · logs.sh</text>
</g>
<!-- l1&#45;&gt;l4 -->
<g id="edge3" class="edge">
<title>l1&#45;&gt;l4</title>
<path fill="none" stroke="#9aa5b1" d="M232.31,-322.61C245.18,-309.92 260.34,-294.97 273.8,-281.7"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="275.51,-283.45 278.78,-276.79 272.07,-279.96 275.51,-283.45"/>
<text xml:space="preserve" text-anchor="middle" x="275.71" y="-295.95" font-family="Arial" font-size="9.00" fill="#616e7c">optional</text>
</g>
<!-- l3 -->
<g id="node5" class="node">
<title>l3</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="10.88,-108 10.88,-168 182.12,-168 182.12,-108 10.88,-108"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-154.5" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 3</text>
<polyline fill="none" stroke="#9aa5b1" points="10.88,-148 182.12,-148"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-134.5" font-family="Arial" font-size="10.00" fill="#1f2933">Link</text>
<polyline fill="none" stroke="#9aa5b1" points="10.88,-128 182.12,-128"/>
<text xml:space="preserve" text-anchor="middle" x="96.5" y="-114.5" font-family="Arial" font-size="10.00" fill="#1f2933">link/main.py · adapters/ · Dockerfile</text>
</g>
<!-- l2&#45;&gt;l3 -->
<g id="edge4" class="edge">
<title>l2&#45;&gt;l3</title>
<path fill="none" stroke="#9aa5b1" d="M96.5,-215.11C96.5,-203.22 96.5,-189.35 96.5,-176.73"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="98.95,-176.74 96.5,-169.74 94.05,-176.74 98.95,-176.74"/>
<text xml:space="preserve" text-anchor="middle" x="113" y="-188.45" font-family="Arial" font-size="9.00" fill="#616e7c">optional</text>
</g>
<!-- l5 -->
<g id="node6" class="node">
<title>l5</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="231,-108 231,-168 388,-168 388,-108 231,-108"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-154.5" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 5</text>
<polyline fill="none" stroke="#9aa5b1" points="231,-148 388,-148"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-134.5" font-family="Arial" font-size="10.00" fill="#1f2933">Systems</text>
<polyline fill="none" stroke="#9aa5b1" points="231,-128 388,-128"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-114.5" font-family="Arial" font-size="10.00" fill="#1f2933">tester/environments.json · tests/</text>
</g>
<!-- l4&#45;&gt;l5 -->
<g id="edge5" class="edge">
<title>l4&#45;&gt;l5</title>
<path fill="none" stroke="#9aa5b1" d="M309.5,-215.11C309.5,-203.22 309.5,-189.35 309.5,-176.73"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="311.95,-176.74 309.5,-169.74 307.05,-176.74 311.95,-176.74"/>
<text xml:space="preserve" text-anchor="middle" x="326" y="-188.45" font-family="Arial" font-size="9.00" fill="#616e7c">optional</text>
</g>
<!-- l6 -->
<g id="node7" class="node">
<title>l6</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="204.38,-0.5 204.38,-60.5 414.62,-60.5 414.62,-0.5 204.38,-0.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-47" font-family="Arial" font-size="10.00" fill="#1f2933">Layer 6</text>
<polyline fill="none" stroke="#9aa5b1" points="204.38,-40.5 414.62,-40.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-27" font-family="Arial" font-size="10.00" fill="#1f2933">Nginx</text>
<polyline fill="none" stroke="#9aa5b1" points="204.38,-20.5 414.62,-20.5"/>
<text xml:space="preserve" text-anchor="middle" x="309.5" y="-7" font-family="Arial" font-size="10.00" fill="#1f2933">nginx/local.conf · docker&#45;compose.nginx.yml</text>
</g>
<!-- l5&#45;&gt;l6 -->
<g id="edge6" class="edge">
<title>l5&#45;&gt;l6</title>
<path fill="none" stroke="#9aa5b1" d="M309.5,-107.61C309.5,-95.72 309.5,-81.85 309.5,-69.23"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="311.95,-69.24 309.5,-62.24 307.05,-69.24 311.95,-69.24"/>
<text xml:space="preserve" text-anchor="middle" x="329.75" y="-80.95" font-family="Arial" font-size="9.00" fill="#616e7c">if frontend</text>
</g>
<!-- note_req -->
<g id="node8" class="node accent">
<title>note_req</title>
<polygon fill="#d6e4ff" stroke="none" points="233.38,-564 171.62,-564 171.62,-528 233.38,-528 233.38,-564"/>
<text xml:space="preserve" text-anchor="middle" x="202.5" y="-542.7" font-family="Arial" font-size="9.00" fill="#1f2933">every room</text>
</g>
<!-- note_req&#45;&gt;l0 -->
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -11,7 +11,7 @@
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-607.5 456.62,-607.5 456.62,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="226.31" y="-586.2" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Room Layers — init wizard</text>
<!-- l0 -->
<g id="node1" class="node">
<g id="node1" class="node accent">
<title>l0</title>
<polygon fill="#1a1a1a" stroke="#d4a574" points="155.12,-442.5 155.12,-504.75 285.88,-504.75 285.88,-442.5 155.12,-442.5"/>
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-491.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 0</text>
@@ -55,7 +55,7 @@
<text xml:space="preserve" text-anchor="middle" x="191.95" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
</g>
<!-- l4 -->
<g id="node5" class="node">
<g id="node4" class="node">
<title>l4</title>
<polygon fill="#1a1a1a" stroke="#333333" points="229.25,-221.5 229.25,-283.75 441.75,-283.75 441.75,-221.5 229.25,-221.5"/>
<text xml:space="preserve" text-anchor="middle" x="335.5" y="-270.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 4</text>
@@ -65,14 +65,14 @@
<text xml:space="preserve" text-anchor="middle" x="335.5" y="-228.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">ctrl/start.sh · stop.sh · status.sh · logs.sh</text>
</g>
<!-- l1&#45;&gt;l4 -->
<g id="edge4" class="edge">
<g id="edge3" class="edge">
<title>l1&#45;&gt;l4</title>
<path fill="none" stroke="#666666" d="M252.84,-331.61C266.06,-319.14 281.48,-304.59 295.41,-291.45"/>
<polygon fill="#666666" stroke="#666666" points="297.43,-294.36 302.3,-284.95 292.63,-289.26 297.43,-294.36"/>
<text xml:space="preserve" text-anchor="middle" x="299.45" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">optional</text>
</g>
<!-- l3 -->
<g id="node4" class="node">
<g id="node5" class="node">
<title>l3</title>
<polygon fill="#1a1a1a" stroke="#333333" points="10.88,-111 10.88,-173.25 200.12,-173.25 200.12,-111 10.88,-111"/>
<text xml:space="preserve" text-anchor="middle" x="105.5" y="-159.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 3</text>
@@ -82,7 +82,7 @@
<text xml:space="preserve" text-anchor="middle" x="105.5" y="-118.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">link/main.py · adapters/ · Dockerfile</text>
</g>
<!-- l2&#45;&gt;l3 -->
<g id="edge3" class="edge">
<g id="edge4" class="edge">
<title>l2&#45;&gt;l3</title>
<path fill="none" stroke="#666666" d="M105.5,-221.11C105.5,-209.81 105.5,-196.79 105.5,-184.67"/>
<polygon fill="#666666" stroke="#666666" points="109,-184.92 105.5,-174.92 102,-184.92 109,-184.92"/>
@@ -123,10 +123,10 @@
<text xml:space="preserve" text-anchor="middle" x="359.12" y="-83.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if frontend</text>
</g>
<!-- note_req -->
<g id="node8" class="node">
<g id="node8" class="node accent">
<title>note_req</title>
<polygon fill="#0a0a0a" stroke="none" points="254,-578.25 187,-578.25 187,-542.25 254,-542.25 254,-578.25"/>
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-557.33" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">every room</text>
<polygon fill="#1a1a1a" stroke="none" points="254,-578.25 187,-578.25 187,-542.25 254,-542.25 254,-578.25"/>
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-557.33" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e5e5e5">every room</text>
</g>
<!-- note_req&#45;&gt;l0 -->
</g>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -1,88 +1,78 @@
digraph system_overview {
bgcolor="#0a0a0a"
rankdir=TB
compound=true
fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5"]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
node [fontname="Helvetica" fontsize=11 style=filled]
edge [fontname="Helvetica" fontsize=9]
label="Soleprint — System Overview"
labelloc=t
fontsize=14
fontcolor="#d4a574"
// Core
subgraph cluster_core {
label="Soleprint Hub"
style=dashed
color="#d4a574"
fontcolor="#d4a574"
class="accent"
hub [label="soleprint\ncore coordinator\nport 12000" fillcolor="#1a1a1a" shape=box]
hub [label="soleprint\ncore coordinator\nport 12000" shape=box]
}
// Artery
subgraph cluster_artery {
label="Artery — Todo lo vital"
style=dashed
color="#b91c1c"
fontcolor="#fca5a5"
class="artery"
veins [label="Veins\nstateless connectors" fillcolor="#1a1a1a"]
shunts [label="Shunts\nmock connectors" fillcolor="#1a1a1a"]
pulses [label="Pulses\ncomposed flows" fillcolor="#1a1a1a"]
veins [label="Veins\nstateless connectors"]
shunts [label="Shunts\nmock connectors"]
pulses [label="Pulses\ncomposed flows"]
}
// Atlas
subgraph cluster_atlas {
label="Atlas — Documentacion accionable"
style=dashed
color="#15803d"
fontcolor="#86efac"
class="atlas"
books [label="Books\ndocumentation" fillcolor="#1a1a1a"]
templates [label="Templates\npatterns" fillcolor="#1a1a1a"]
books [label="Books\ndocumentation"]
templates [label="Templates\npatterns"]
}
// Station
subgraph cluster_station {
label="Station — Centro de control"
style=dashed
color="#1d4ed8"
fontcolor="#93c5fd"
class="station"
tools [label="Tools\ntester · datagen · modelgen" fillcolor="#1a1a1a"]
monitors [label="Monitors\ndatabrowse" fillcolor="#1a1a1a"]
tools [label="Tools\ntester · datagen · modelgen"]
monitors [label="Monitors\ndatabrowse"]
}
// External
subgraph cluster_external {
label="External APIs"
style=dashed
color="#333"
fontcolor="#666"
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
google [label="Google" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
slack [label="Slack" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
jira [label="Jira"]
google [label="Google"]
slack [label="Slack"]
}
// Managed app
subgraph cluster_managed {
label="Managed App"
style=dashed
color="#333"
fontcolor="#666"
app_fe [label="Frontend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
app_be [label="Backend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
app_db [label="Database" fillcolor="#1a1a1a" fontcolor="#a3a3a3" shape=cylinder]
app_fe [label="Frontend"]
app_be [label="Backend"]
app_db [label="Database" shape=cylinder]
}
// Connections
hub -> veins [label="routes" color="#b91c1c"]
hub -> books [label="routes" color="#15803d"]
hub -> tools [label="routes" color="#1d4ed8"]
hub -> veins [class="artery" label="routes"]
hub -> books [class="atlas" label="routes"]
hub -> tools [class="station" label="routes"]
veins -> jira [label="API"]
veins -> google [label="OAuth"]
@@ -93,5 +83,5 @@ digraph system_overview {
monitors -> app_db [label="browse" style=dashed]
// Sidebar injection
hub -> app_fe [label="sidebar\ninjection" color="#d4a574" style=dashed]
hub -> app_fe [class="accent" label="sidebar\ninjection" style=dashed]
}

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: system_overview Pages: 1 -->
<svg width="1038pt" height="365pt"
viewBox="0.00 0.00 1038.00 365.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 360.75)">
<title>system_overview</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-360.75 1034,-360.75 1034,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="515" y="-339.45" font-family="Arial" font-size="14.00" fill="#1f2933">Soleprint — System Overview</text>
<g id="clust1" class="cluster accent">
<title>cluster_core</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M493,-236.75C493,-236.75 579,-236.75 579,-236.75 585,-236.75 591,-242.75 591,-248.75 591,-248.75 591,-313 591,-313 591,-319 585,-325 579,-325 579,-325 493,-325 493,-325 487,-325 481,-319 481,-313 481,-313 481,-248.75 481,-248.75 481,-242.75 487,-236.75 493,-236.75"/>
<text xml:space="preserve" text-anchor="middle" x="536" y="-307.7" font-family="Arial" font-size="14.00" fill="#616e7c">Soleprint Hub</text>
</g>
<g id="clust2" class="cluster artery">
<title>cluster_artery</title>
<path fill="#fdeaea" stroke="#c0392b" stroke-dasharray="5,2" d="M20,-8C20,-8 328,-8 328,-8 334,-8 340,-14 340,-20 340,-20 340,-196.25 340,-196.25 340,-202.25 334,-208.25 328,-208.25 328,-208.25 20,-208.25 20,-208.25 14,-208.25 8,-202.25 8,-196.25 8,-196.25 8,-20 8,-20 8,-14 14,-8 20,-8"/>
<text xml:space="preserve" text-anchor="middle" x="174" y="-190.95" font-family="Arial" font-size="14.00" fill="#616e7c">Artery — Todo lo vital</text>
</g>
<g id="clust3" class="cluster atlas">
<title>cluster_atlas</title>
<path fill="#e6f5ec" stroke="#1a7f45" stroke-dasharray="5,2" d="M360,-119C360,-119 584,-119 584,-119 590,-119 596,-125 596,-131 596,-131 596,-196.25 596,-196.25 596,-202.25 590,-208.25 584,-208.25 584,-208.25 360,-208.25 360,-208.25 354,-208.25 348,-202.25 348,-196.25 348,-196.25 348,-131 348,-131 348,-125 354,-119 360,-119"/>
<text xml:space="preserve" text-anchor="middle" x="472" y="-190.95" font-family="Arial" font-size="14.00" fill="#616e7c">Atlas — Documentacion accionable</text>
</g>
<g id="clust4" class="cluster station">
<title>cluster_station</title>
<path fill="#e8effd" stroke="#2b5fd9" stroke-dasharray="5,2" d="M616,-119C616,-119 938,-119 938,-119 944,-119 950,-125 950,-131 950,-131 950,-196.25 950,-196.25 950,-202.25 944,-208.25 938,-208.25 938,-208.25 616,-208.25 616,-208.25 610,-208.25 604,-202.25 604,-196.25 604,-196.25 604,-131 604,-131 604,-125 610,-119 616,-119"/>
<text xml:space="preserve" text-anchor="middle" x="777" y="-190.95" font-family="Arial" font-size="14.00" fill="#616e7c">Station — Centro de control</text>
</g>
<g id="clust5" class="cluster">
<title>cluster_external</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M360,-14.75C360,-14.75 558,-14.75 558,-14.75 564,-14.75 570,-20.75 570,-26.75 570,-26.75 570,-78.5 570,-78.5 570,-84.5 564,-90.5 558,-90.5 558,-90.5 360,-90.5 360,-90.5 354,-90.5 348,-84.5 348,-78.5 348,-78.5 348,-26.75 348,-26.75 348,-20.75 354,-14.75 360,-14.75"/>
<text xml:space="preserve" text-anchor="middle" x="459" y="-73.2" font-family="Arial" font-size="14.00" fill="#616e7c">External APIs</text>
</g>
<g id="clust6" class="cluster">
<title>cluster_managed</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M774,-14.75C774,-14.75 1010,-14.75 1010,-14.75 1016,-14.75 1022,-20.75 1022,-26.75 1022,-26.75 1022,-78.5 1022,-78.5 1022,-84.5 1016,-90.5 1010,-90.5 1010,-90.5 774,-90.5 774,-90.5 768,-90.5 762,-84.5 762,-78.5 762,-78.5 762,-26.75 762,-26.75 762,-20.75 768,-14.75 774,-14.75"/>
<text xml:space="preserve" text-anchor="middle" x="892" y="-73.2" font-family="Arial" font-size="14.00" fill="#616e7c">Managed App</text>
</g>
<!-- hub -->
<g id="node1" class="node">
<title>hub</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M571,-293.25C571,-293.25 501,-293.25 501,-293.25 495,-293.25 489,-287.25 489,-281.25 489,-281.25 489,-256.75 489,-256.75 489,-250.75 495,-244.75 501,-244.75 501,-244.75 571,-244.75 571,-244.75 577,-244.75 583,-250.75 583,-256.75 583,-256.75 583,-281.25 583,-281.25 583,-287.25 577,-293.25 571,-293.25"/>
<text xml:space="preserve" text-anchor="middle" x="536" y="-278.8" font-family="Arial" font-size="11.00" fill="#1f2933">soleprint</text>
<text xml:space="preserve" text-anchor="middle" x="536" y="-265.3" font-family="Arial" font-size="11.00" fill="#1f2933">core coordinator</text>
<text xml:space="preserve" text-anchor="middle" x="536" y="-251.8" font-family="Arial" font-size="11.00" fill="#1f2933">port 12000</text>
</g>
<!-- veins -->
<g id="node2" class="node">
<title>veins</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="96" cy="-151.75" rx="80.26" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="96" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Veins</text>
<text xml:space="preserve" text-anchor="middle" x="96" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">stateless connectors</text>
</g>
<!-- hub&#45;&gt;veins -->
<g id="edge1" class="edge artery">
<title>hub&#45;&gt;veins</title>
<path fill="none" stroke="#c0392b" d="M488.78,-265.75C419.92,-261.21 288.87,-247.71 185,-208.25 166.55,-201.24 147.77,-190.21 132.24,-179.83"/>
<polygon fill="#c0392b" stroke="#c0392b" points="133.76,-177.9 126.6,-175.98 131,-181.95 133.76,-177.9"/>
<text xml:space="preserve" text-anchor="middle" x="252.94" y="-218.2" font-family="Arial" font-size="9.00" fill="#c0392b">routes</text>
</g>
<!-- books -->
<g id="node5" class="node">
<title>books</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="417" cy="-151.75" rx="61.16" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="417" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Books</text>
<text xml:space="preserve" text-anchor="middle" x="417" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">documentation</text>
</g>
<!-- hub&#45;&gt;books -->
<g id="edge2" class="edge atlas">
<title>hub&#45;&gt;books</title>
<path fill="none" stroke="#1a7f45" d="M511.63,-244.4C492.65,-226.01 466.24,-200.44 446.14,-180.97"/>
<polygon fill="#1a7f45" stroke="#1a7f45" points="447.94,-179.3 441.21,-176.19 444.53,-182.82 447.94,-179.3"/>
<text xml:space="preserve" text-anchor="middle" x="503.41" y="-218.2" font-family="Arial" font-size="9.00" fill="#1a7f45">routes</text>
</g>
<!-- tools -->
<g id="node7" class="node">
<title>tools</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="717" cy="-151.75" rx="105.18" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="717" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Tools</text>
<text xml:space="preserve" text-anchor="middle" x="717" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">tester · datagen · modelgen</text>
</g>
<!-- hub&#45;&gt;tools -->
<g id="edge3" class="edge station">
<title>hub&#45;&gt;tools</title>
<path fill="none" stroke="#2b5fd9" d="M573.06,-244.4C602.58,-225.6 643.92,-199.28 674.73,-179.67"/>
<polygon fill="#2b5fd9" stroke="#2b5fd9" points="675.66,-181.98 680.25,-176.15 673.03,-177.84 675.66,-181.98"/>
<text xml:space="preserve" text-anchor="middle" x="628.41" y="-218.2" font-family="Arial" font-size="9.00" fill="#2b5fd9">routes</text>
</g>
<!-- app_fe -->
<g id="node12" class="node">
<title>app_fe</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="977" cy="-40.75" rx="37.09" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="977" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Frontend</text>
</g>
<!-- hub&#45;&gt;app_fe -->
<g id="edge4" class="edge accent">
<title>hub&#45;&gt;app_fe</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M583.13,-268.1C681.31,-267.28 902.87,-259.62 954,-208.25 991.07,-171.01 987.37,-103.78 981.91,-67.18"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="984.37,-67.01 980.83,-60.49 979.53,-67.79 984.37,-67.01"/>
<text xml:space="preserve" text-anchor="middle" x="1002.46" y="-153.7" font-family="Arial" font-size="9.00" fill="#3a7dff">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="1002.46" y="-143.2" font-family="Arial" font-size="9.00" fill="#3a7dff">injection</text>
</g>
<!-- pulses -->
<g id="node4" class="node">
<title>pulses</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="96" cy="-40.75" rx="65.94" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="96" y="-43.8" font-family="Arial" font-size="11.00" fill="#1f2933">Pulses</text>
<text xml:space="preserve" text-anchor="middle" x="96" y="-30.3" font-family="Arial" font-size="11.00" fill="#1f2933">composed flows</text>
</g>
<!-- veins&#45;&gt;pulses -->
<g id="edge5" class="edge">
<title>veins&#45;&gt;pulses</title>
<path fill="none" stroke="#9aa5b1" d="M96,-126.56C96,-111.18 96,-91.07 96,-74.3"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="98.45,-74.38 96,-67.39 93.55,-74.39 98.45,-74.38"/>
<text xml:space="preserve" text-anchor="middle" x="114.75" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">compose</text>
</g>
<!-- jira -->
<g id="node9" class="node">
<title>jira</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="383" cy="-40.75" rx="27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="383" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Jira</text>
</g>
<!-- veins&#45;&gt;jira -->
<g id="edge6" class="edge">
<title>veins&#45;&gt;jira</title>
<path fill="none" stroke="#9aa5b1" d="M144.51,-131.81C157.52,-127.18 171.68,-122.54 185,-119 254.38,-100.54 282.3,-127.21 344,-90.5 354.39,-84.32 363.13,-74.26 369.69,-64.89"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="371.64,-66.39 373.44,-59.19 367.54,-63.69 371.64,-66.39"/>
<text xml:space="preserve" text-anchor="middle" x="333.55" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">API</text>
</g>
<!-- google -->
<g id="node10" class="node">
<title>google</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="459" cy="-40.75" rx="31.48" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="459" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Google</text>
</g>
<!-- veins&#45;&gt;google -->
<g id="edge7" class="edge">
<title>veins&#45;&gt;google</title>
<path fill="none" stroke="#9aa5b1" d="M142.87,-131.26C156.26,-126.46 171.02,-121.87 185,-119 287.63,-97.92 330.48,-146.53 419,-90.5 429.17,-84.06 437.99,-74.16 444.73,-64.95"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="446.63,-66.51 448.61,-59.37 442.61,-63.72 446.63,-66.51"/>
<text xml:space="preserve" text-anchor="middle" x="416.29" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">OAuth</text>
</g>
<!-- slack -->
<g id="node11" class="node">
<title>slack</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="535" cy="-40.75" rx="27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="535" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Slack</text>
</g>
<!-- veins&#45;&gt;slack -->
<g id="edge8" class="edge">
<title>veins&#45;&gt;slack</title>
<path fill="none" stroke="#9aa5b1" d="M142.37,-131.22C155.88,-126.36 170.83,-121.75 185,-119 293.3,-98.01 324.04,-126.22 433,-109 463.09,-104.24 473.93,-107.8 499,-90.5 508.43,-83.99 516.35,-74.2 522.34,-65.1"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="524.32,-66.55 525.93,-59.3 520.16,-63.97 524.32,-66.55"/>
<text xml:space="preserve" text-anchor="middle" x="492.52" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">API</text>
</g>
<!-- shunts -->
<g id="node3" class="node">
<title>shunts</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="263" cy="-151.75" rx="68.59" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="263" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Shunts</text>
<text xml:space="preserve" text-anchor="middle" x="263" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">mock connectors</text>
</g>
<!-- templates -->
<g id="node6" class="node">
<title>templates</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="542" cy="-151.75" rx="45.79" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="542" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Templates</text>
<text xml:space="preserve" text-anchor="middle" x="542" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">patterns</text>
</g>
<!-- app_be -->
<g id="node13" class="node">
<title>app_be</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="806" cy="-40.75" rx="36.16" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="806" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Backend</text>
</g>
<!-- tools&#45;&gt;app_be -->
<g id="edge9" class="edge">
<title>tools&#45;&gt;app_be</title>
<path fill="none" stroke="#9aa5b1" stroke-dasharray="5,2" d="M736.31,-127.1C751.52,-108.47 772.58,-82.68 787.56,-64.33"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="789.29,-66.08 791.82,-59.11 785.5,-62.99 789.29,-66.08"/>
<text xml:space="preserve" text-anchor="middle" x="765.96" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">test</text>
</g>
<!-- monitors -->
<g id="node8" class="node">
<title>monitors</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="891" cy="-151.75" rx="51.09" ry="24.75"/>
<text xml:space="preserve" text-anchor="middle" x="891" y="-154.8" font-family="Arial" font-size="11.00" fill="#1f2933">Monitors</text>
<text xml:space="preserve" text-anchor="middle" x="891" y="-141.3" font-family="Arial" font-size="11.00" fill="#1f2933">databrowse</text>
</g>
<!-- app_db -->
<g id="node14" class="node">
<title>app_db</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M922.25,-55.48C922.25,-57.28 908.24,-58.75 891,-58.75 873.76,-58.75 859.75,-57.28 859.75,-55.48 859.75,-55.48 859.75,-26.02 859.75,-26.02 859.75,-24.22 873.76,-22.75 891,-22.75 908.24,-22.75 922.25,-24.22 922.25,-26.02 922.25,-26.02 922.25,-55.48 922.25,-55.48"/>
<path fill="none" stroke="#9aa5b1" d="M922.25,-55.48C922.25,-53.67 908.24,-52.2 891,-52.2 873.76,-52.2 859.75,-53.67 859.75,-55.48"/>
<text xml:space="preserve" text-anchor="middle" x="891" y="-37.05" font-family="Arial" font-size="11.00" fill="#1f2933">Database</text>
</g>
<!-- monitors&#45;&gt;app_db -->
<g id="edge10" class="edge">
<title>monitors&#45;&gt;app_db</title>
<path fill="none" stroke="#9aa5b1" stroke-dasharray="5,2" d="M891,-126.56C891,-108.99 891,-85.25 891,-67.36"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="893.45,-67.61 891,-60.61 888.55,-67.61 893.45,-67.61"/>
<text xml:space="preserve" text-anchor="middle" x="906" y="-100.45" font-family="Arial" font-size="9.00" fill="#616e7c">browse</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -10,22 +10,22 @@
<title>system_overview</title>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-368.25 1149,-368.25 1149,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="572.5" y="-346.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint — System Overview</text>
<g id="clust1" class="cluster">
<g id="clust1" class="cluster accent">
<title>cluster_core</title>
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="538,-241.25 538,-331 660,-331 660,-241.25 538,-241.25"/>
<text xml:space="preserve" text-anchor="middle" x="599" y="-313.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint Hub</text>
</g>
<g id="clust2" class="cluster">
<g id="clust2" class="cluster artery">
<title>cluster_artery</title>
<polygon fill="#0a0a0a" stroke="#b91c1c" stroke-dasharray="5,2" points="8,-8 8,-212 382,-212 382,-8 8,-8"/>
<text xml:space="preserve" text-anchor="middle" x="195" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Todo lo vital</text>
</g>
<g id="clust3" class="cluster">
<g id="clust3" class="cluster atlas">
<title>cluster_atlas</title>
<polygon fill="#0a0a0a" stroke="#15803d" stroke-dasharray="5,2" points="390,-121.25 390,-212 665,-212 665,-121.25 390,-121.25"/>
<text xml:space="preserve" text-anchor="middle" x="527.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#86efac">Atlas — Documentacion accionable</text>
</g>
<g id="clust4" class="cluster">
<g id="clust4" class="cluster station">
<title>cluster_station</title>
<polygon fill="#0a0a0a" stroke="#1d4ed8" stroke-dasharray="5,2" points="673,-121.25 673,-212 1062,-212 1062,-121.25 673,-121.25"/>
<text xml:space="preserve" text-anchor="middle" x="867.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#93c5fd">Station — Centro de control</text>
@@ -56,11 +56,11 @@
<text xml:space="preserve" text-anchor="middle" x="108" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless connectors</text>
</g>
<!-- hub&#45;&gt;veins -->
<g id="edge1" class="edge">
<g id="edge1" class="edge artery">
<title>hub&#45;&gt;veins</title>
<path fill="none" stroke="#b91c1c" d="M545.18,-270.24C468.57,-265.68 324.55,-252.07 209,-212 188.62,-204.93 167.6,-193.79 150.05,-183.23"/>
<polygon fill="#b91c1c" stroke="#b91c1c" points="152.32,-180.52 141.98,-178.24 148.65,-186.47 152.32,-180.52"/>
<text xml:space="preserve" text-anchor="middle" x="284.95" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text>
<text xml:space="preserve" text-anchor="middle" x="284.95" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">routes</text>
</g>
<!-- books -->
<g id="node5" class="node">
@@ -70,11 +70,11 @@
<text xml:space="preserve" text-anchor="middle" x="468" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">documentation</text>
</g>
<!-- hub&#45;&gt;books -->
<g id="edge2" class="edge">
<g id="edge2" class="edge atlas">
<title>hub&#45;&gt;books</title>
<path fill="none" stroke="#15803d" d="M572.81,-249C552.24,-230.55 523.47,-204.75 501.27,-184.84"/>
<polygon fill="#15803d" stroke="#15803d" points="503.73,-182.34 493.94,-178.27 499.05,-187.55 503.73,-182.34"/>
<text xml:space="preserve" text-anchor="middle" x="567.14" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text>
<text xml:space="preserve" text-anchor="middle" x="567.14" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">routes</text>
</g>
<!-- tools -->
<g id="node7" class="node">
@@ -84,25 +84,25 @@
<text xml:space="preserve" text-anchor="middle" x="802" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">tester · datagen · modelgen</text>
</g>
<!-- hub&#45;&gt;tools -->
<g id="edge3" class="edge">
<g id="edge3" class="edge station">
<title>hub&#45;&gt;tools</title>
<path fill="none" stroke="#1d4ed8" d="M639.59,-249C672.13,-230.17 717.93,-203.66 752.6,-183.59"/>
<polygon fill="#1d4ed8" stroke="#1d4ed8" points="754.3,-186.65 761.2,-178.61 750.8,-180.59 754.3,-186.65"/>
<text xml:space="preserve" text-anchor="middle" x="702.6" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text>
<text xml:space="preserve" text-anchor="middle" x="702.6" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#93c5fd">routes</text>
</g>
<!-- app_fe -->
<g id="node12" class="node">
<title>app_fe</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="1089" cy="-40.75" rx="39.9" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="1089" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Frontend</text>
<text xml:space="preserve" text-anchor="middle" x="1089" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text>
</g>
<!-- hub&#45;&gt;app_fe -->
<g id="edge10" class="edge">
<g id="edge4" class="edge accent">
<title>hub&#45;&gt;app_fe</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M652.46,-273.38C762.51,-274.01 1008.83,-268.85 1066,-212 1103.21,-174.99 1100.13,-108.42 1094.57,-70.42"/>
<polygon fill="#d4a574" stroke="#d4a574" points="1098.04,-69.95 1092.97,-60.65 1091.14,-71.08 1098.04,-69.95"/>
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-156.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-145.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">injection</text>
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-156.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">sidebar</text>
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-145.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">injection</text>
</g>
<!-- pulses -->
<g id="node4" class="node">
@@ -112,7 +112,7 @@
<text xml:space="preserve" text-anchor="middle" x="108" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">composed flows</text>
</g>
<!-- veins&#45;&gt;pulses -->
<g id="edge7" class="edge">
<g id="edge5" class="edge">
<title>veins&#45;&gt;pulses</title>
<path fill="none" stroke="#666666" d="M108,-128.86C108,-113.7 108,-93.88 108,-76.98"/>
<polygon fill="#666666" stroke="#666666" points="111.5,-77.35 108,-67.35 104.5,-77.35 111.5,-77.35"/>
@@ -122,10 +122,10 @@
<g id="node9" class="node">
<title>jira</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="425" cy="-40.75" rx="27" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="425" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Jira</text>
<text xml:space="preserve" text-anchor="middle" x="425" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Jira</text>
</g>
<!-- veins&#45;&gt;jira -->
<g id="edge4" class="edge">
<g id="edge6" class="edge">
<title>veins&#45;&gt;jira</title>
<path fill="none" stroke="#666666" d="M163.97,-133.86C178.52,-129.32 194.25,-124.77 209,-121.25 286.56,-102.75 317.5,-132.81 386,-92 395.89,-86.1 404.2,-76.67 410.57,-67.57"/>
<polygon fill="#666666" stroke="#666666" points="413.44,-69.58 415.88,-59.27 407.54,-65.8 413.44,-69.58"/>
@@ -135,10 +135,10 @@
<g id="node10" class="node">
<title>google</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="504" cy="-40.75" rx="33.82" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="504" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Google</text>
<text xml:space="preserve" text-anchor="middle" x="504" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Google</text>
</g>
<!-- veins&#45;&gt;google -->
<g id="edge5" class="edge">
<g id="edge7" class="edge">
<title>veins&#45;&gt;google</title>
<path fill="none" stroke="#666666" d="M161.63,-133.42C176.76,-128.65 193.36,-124.1 209,-121.25 287.39,-106.97 309.3,-123.69 388,-111.25 421.14,-106.01 432.58,-109.83 461,-92 471.11,-85.66 480.09,-76.19 487.2,-67.17"/>
<polygon fill="#666666" stroke="#666666" points="489.88,-69.43 493,-59.31 484.25,-65.28 489.88,-69.43"/>
@@ -148,10 +148,10 @@
<g id="node11" class="node">
<title>slack</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="584" cy="-40.75" rx="27.74" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="584" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Slack</text>
<text xml:space="preserve" text-anchor="middle" x="584" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Slack</text>
</g>
<!-- veins&#45;&gt;slack -->
<g id="edge6" class="edge">
<g id="edge8" class="edge">
<title>veins&#45;&gt;slack</title>
<path fill="none" stroke="#666666" d="M161.07,-133.39C176.34,-128.56 193.15,-123.98 209,-121.25 326.9,-100.91 359.79,-129.67 478,-111.25 509.46,-106.35 520.8,-110.08 547,-92 556.07,-85.74 563.77,-76.52 569.76,-67.68"/>
<polygon fill="#666666" stroke="#666666" points="572.65,-69.66 574.98,-59.33 566.72,-65.96 572.65,-69.66"/>
@@ -175,10 +175,10 @@
<g id="node13" class="node">
<title>app_be</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="906" cy="-40.75" rx="38.96" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="906" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Backend</text>
<text xml:space="preserve" text-anchor="middle" x="906" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend</text>
</g>
<!-- tools&#45;&gt;app_be -->
<g id="edge8" class="edge">
<g id="edge9" class="edge">
<title>tools&#45;&gt;app_be</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M824.06,-129.4C841.45,-110.79 865.64,-84.93 883.22,-66.12"/>
<polygon fill="#666666" stroke="#666666" points="885.72,-68.57 889.99,-58.88 880.6,-63.79 885.72,-68.57"/>
@@ -196,10 +196,10 @@
<title>app_db</title>
<path fill="#1a1a1a" stroke="#333333" d="M1031.25,-55.48C1031.25,-57.28 1015.9,-58.75 997,-58.75 978.1,-58.75 962.75,-57.28 962.75,-55.48 962.75,-55.48 962.75,-26.02 962.75,-26.02 962.75,-24.22 978.1,-22.75 997,-22.75 1015.9,-22.75 1031.25,-24.22 1031.25,-26.02 1031.25,-26.02 1031.25,-55.48 1031.25,-55.48"/>
<path fill="none" stroke="#333333" d="M1031.25,-55.48C1031.25,-53.67 1015.9,-52.2 997,-52.2 978.1,-52.2 962.75,-53.67 962.75,-55.48"/>
<text xml:space="preserve" text-anchor="middle" x="997" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Database</text>
<text xml:space="preserve" text-anchor="middle" x="997" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Database</text>
</g>
<!-- monitors&#45;&gt;app_db -->
<g id="edge9" class="edge">
<g id="edge10" class="edge">
<title>monitors&#45;&gt;app_db</title>
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M997,-128.86C997,-111.64 997,-88.42 997,-70.29"/>
<polygon fill="#666666" stroke="#666666" points="1000.5,-70.46 997,-60.46 993.5,-70.46 1000.5,-70.46"/>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,92 @@
// Dark — the palette the docs have always used, restated as a theme.
//
// This exists so the .dot sources can stop carrying colour. It reproduces what
// the committed SVGs look like today, which also makes it the regression
// oracle: strip the inline colours from a source, render it through this, and
// the result should be the diagram you had before. If it is not, the strip was
// wrong.
//
// Palette matches common/theme/themes/soleprint.css, so a diagram and the page
// around it are the same visual language.
BEGIN {
string BG = "#0a0a0a";
string FILL = "#1a1a1a";
string LINE = "#333333";
string INK = "#e5e5e5";
string MUTED = "#a3a3a3";
string DIM = "#666666";
string FONT = "Helvetica";
graph_t sg;
// The soleprint amber, and the three system colours, each with the lighter
// text variant the docs use for labels on dark.
string ACC_S = "#d4a574"; string ACC_T = "#d4a574";
string ART_S = "#b91c1c"; string ART_T = "#fca5a5";
string ATL_S = "#15803d"; string ATL_T = "#86efac";
string STA_S = "#1d4ed8"; string STA_T = "#93c5fd";
}
BEG_G {
$G.bgcolor = BG;
setDflt($G, "N", "fontname", FONT);
setDflt($G, "E", "fontname", FONT);
// Declare `class` so reading it on an untagged object is defined
// rather than a warning — most nodes carry no class by design.
setDflt($G, "G", "class", "");
setDflt($G, "N", "class", "");
setDflt($G, "E", "class", "");
$G.fontname = FONT;
// The graph title takes the accent of whatever system the graph is about;
// graphs declare that with a class on the digraph itself.
if ($G.class == "artery") { $G.fontcolor = ART_T; }
else if ($G.class == "atlas") { $G.fontcolor = ATL_T; }
else if ($G.class == "station") { $G.fontcolor = STA_T; }
else { $G.fontcolor = ACC_T; }
for (sg = fstsubg($G); sg; sg = nxtsubg(sg)) {
if (index(sg.name, "cluster") == 0) {
sg.fontname = FONT;
sg.color = LINE;
sg.fontcolor = DIM;
if (sg.class == "artery") { sg.color = ART_S; sg.fontcolor = ART_T; }
else if (sg.class == "atlas") { sg.color = ATL_S; sg.fontcolor = ATL_T; }
else if (sg.class == "station") { sg.color = STA_S; sg.fontcolor = STA_T; }
else if (sg.class == "accent") { sg.color = ACC_S; sg.fontcolor = ACC_T; }
else if (sg.class == "ok") { sg.color = ATL_S; sg.fontcolor = ATL_T; }
if (index(sg.style, "dashed") < 0) { sg.style = "dashed"; }
}
}
}
N {
if (index($.style, "invis") >= 0) { continue; }
$.fontname = FONT;
$.fontcolor = INK;
$.color = LINE;
$.fillcolor = FILL;
if ($.class == "accent") { $.color = ACC_S; }
else if ($.class == "artery") { $.color = ART_S; }
else if ($.class == "atlas") { $.color = ATL_S; }
else if ($.class == "station") { $.color = STA_S; }
else if ($.class == "accent-text") { $.fontcolor = ACC_T; }
else if ($.class == "ok") { $.fontcolor = ATL_T; }
else if ($.class == "muted") { $.fontcolor = MUTED; }
if (index($.style, "dashed") >= 0) { $.style = "filled,dashed"; }
else { $.style = "filled"; }
}
E {
$.fontname = FONT;
$.fontcolor = MUTED;
$.color = DIM;
if ($.class == "accent") { $.color = ACC_S; $.fontcolor = ACC_T; }
else if ($.class == "artery") { $.color = ART_S; $.fontcolor = ART_T; }
else if ($.class == "atlas") { $.color = ATL_S; $.fontcolor = ATL_T; }
else if ($.class == "station") { $.color = STA_S; $.fontcolor = STA_T; }
}

View File

@@ -0,0 +1,114 @@
// Lucid — graphviz output shaped after a lucid.app export.
//
// Run through gvpr, not as .dot attributes, because gvpr rewrites the parsed
// graph: it wins over whatever the source set inline, so one .dot renders in
// any theme without being edited. See render.sh.
//
// The look, and why each part is what it is:
// white canvas this ends up in a document and gets printed
// rounded box, hairline Lucid's default shape is a rounded rect with a grey
// 1px stroke, not a coloured fill
// pale-blue accent #d6e4ff on #3a7dff is Lucid's own selected-shape pair
// Arial the one face guaranteed on Windows, and aliased to
// Liberation Sans by fontconfig on Linux — so the SVG
// measures the same on both and the text does not
// reflow out of its box
//
// Structure is preserved, only palette is replaced. Two styles carry meaning
// rather than decoration and are explicitly kept: `invis` (a layout spacer —
// overwriting it makes hidden scaffolding visible) and `dashed` (a weaker
// relationship). Shapes are never touched: a cylinder is a datastore.
BEGIN {
string BG = "#ffffff";
string INK = "#1f2933";
string MUTED = "#616e7c";
string LINE = "#9aa5b1";
string SOFT = "#cbd2d9";
string FONT = "Arial";
graph_t sg;
string FILL = "#ffffff";
string FILL_ALT = "#f5f7fa";
// class -> (stroke, fill). Lucid's palette for emphasised shapes.
string ACC_S = "#3a7dff"; string ACC_F = "#d6e4ff";
string ART_S = "#c0392b"; string ART_F = "#fdeaea";
string ATL_S = "#1a7f45"; string ATL_F = "#e6f5ec";
string STA_S = "#2b5fd9"; string STA_F = "#e8effd";
}
BEG_G {
$G.bgcolor = BG;
$G.fontname = FONT;
$G.fontcolor = INK;
// Defaults for anything the per-node block does not reach.
setDflt($G, "N", "fontname", FONT);
setDflt($G, "E", "fontname", FONT);
// Declare `class` so reading it on an untagged object is defined
// rather than a warning — most nodes carry no class by design.
setDflt($G, "G", "class", "");
setDflt($G, "N", "class", "");
setDflt($G, "E", "class", "");
// Clusters: a pale container, the way Lucid draws a grouping box.
for (sg = fstsubg($G); sg; sg = nxtsubg(sg)) {
if (index(sg.name, "cluster") == 0) {
sg.fontname = FONT;
sg.color = SOFT;
sg.fontcolor = MUTED;
sg.bgcolor = FILL_ALT;
// A tagged container keeps its identity, the way a Lucid swimlane
// is tinted by what it holds.
if (sg.class == "artery") { sg.color = ART_S; sg.bgcolor = ART_F; }
else if (sg.class == "atlas") { sg.color = ATL_S; sg.bgcolor = ATL_F; }
else if (sg.class == "station") { sg.color = STA_S; sg.bgcolor = STA_F; }
else if (sg.class == "accent") { sg.color = ACC_S; sg.bgcolor = ACC_F; }
// Keep dashed where the source chose it; it reads as "logical
// grouping" rather than "deployed boundary".
if (index(sg.style, "dashed") < 0) { sg.style = "rounded"; }
else { sg.style = "dashed,rounded"; }
}
}
}
N {
// An invisible node is layout scaffolding. Filling it would draw it.
if (index($.style, "invis") >= 0) { continue; }
$.fontname = FONT;
$.fontcolor = INK;
$.color = LINE;
$.fillcolor = FILL;
$.penwidth = 1;
if ($.class == "accent") { $.color = ACC_S; $.fillcolor = ACC_F; }
else if ($.class == "artery") { $.color = ART_S; $.fillcolor = ART_F; }
else if ($.class == "atlas") { $.color = ATL_S; $.fillcolor = ATL_F; }
else if ($.class == "station") { $.color = STA_S; $.fillcolor = STA_F; }
else if ($.class == "accent-text") { $.fontcolor = ACC_S; }
else if ($.class == "ok") { $.color = ATL_S; $.fillcolor = ATL_F; }
else if ($.class == "muted") { $.fillcolor = FILL_ALT; $.fontcolor = MUTED; }
// `record` ignores rounding, and plaintext has no box to round.
if ($.shape == "record" || $.shape == "Mrecord" || $.shape == "plaintext") {
$.style = "filled";
} else if (index($.style, "dashed") >= 0) {
$.style = "filled,rounded,dashed";
} else {
$.style = "filled,rounded";
}
}
E {
$.fontname = FONT;
$.fontcolor = MUTED;
$.color = LINE;
$.arrowsize = 0.7;
if ($.class == "accent") { $.color = ACC_S; $.fontcolor = ACC_S; }
else if ($.class == "artery") { $.color = ART_S; $.fontcolor = ART_S; }
else if ($.class == "atlas") { $.color = ATL_S; $.fontcolor = ATL_S; }
else if ($.class == "station") { $.color = STA_S; $.fontcolor = STA_S; }
}

View File

@@ -1,45 +1,39 @@
digraph wrapping {
bgcolor="#0a0a0a"
rankdir=LR
fontname="Helvetica"
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
edge [fontname="Helvetica" fontsize=9]
label="Sidebar Injection — How Wrapping Works"
labelloc=t
fontsize=14
fontcolor="#d4a574"
browser [label="Browser" fillcolor="#1a1a1a" shape=oval]
browser [label="Browser" shape=oval]
subgraph cluster_nginx {
label="Nginx (reverse proxy)"
style=dashed
color="#d4a574"
fontcolor="#d4a574"
class="accent"
proxy [label="proxy_pass\n+\nsub_filter\ninjects sidebar" fillcolor="#1a1a1a" shape=component]
proxy [label="proxy_pass\n+\nsub_filter\ninjects sidebar" shape=component]
}
subgraph cluster_app {
label="Managed App"
style=dashed
color="#333"
fontcolor="#666"
frontend [label="Frontend\n(React/Next/Vue)" fillcolor="#1a1a1a"]
backend [label="Backend API" fillcolor="#1a1a1a"]
frontend [label="Frontend\n(React/Next/Vue)"]
backend [label="Backend API"]
}
subgraph cluster_spr {
label="Soleprint"
style=dashed
color="#d4a574"
fontcolor="#d4a574"
class="accent"
sidebar_css [label="sidebar.css" fillcolor="#1a1a1a"]
sidebar_js [label="sidebar.js" fillcolor="#1a1a1a"]
hub [label="Hub API\n/api/sidebar/config" fillcolor="#1a1a1a"]
sidebar_css [label="sidebar.css"]
sidebar_js [label="sidebar.js"]
hub [label="Hub API\n/api/sidebar/config"]
}
browser -> proxy [label="myroom.spr.local.ar"]
@@ -47,8 +41,8 @@ digraph wrapping {
proxy -> hub [label="/spr/ → soleprint"]
// The injection
proxy -> sidebar_css [label="injects into </head>" color="#d4a574" style=dashed]
proxy -> sidebar_js [color="#d4a574" style=dashed]
proxy -> sidebar_css [class="accent" label="injects into </head>" style=dashed]
proxy -> sidebar_js [class="accent" style=dashed]
sidebar_js -> hub [label="loads config" color="#d4a574"]
sidebar_js -> hub [class="accent" label="loads config"]
}

View File

@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: wrapping Pages: 1 -->
<svg width="742pt" height="325pt"
viewBox="0.00 0.00 742.00 325.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 321.19)">
<title>wrapping</title>
<polygon fill="#ffffff" stroke="none" points="-4,4 -4,-321.19 738.19,-321.19 738.19,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="367.1" y="-299.89" font-family="Arial" font-size="14.00" fill="#1f2933">Sidebar Injection — How Wrapping Works</text>
<g id="clust1" class="cluster accent">
<title>cluster_nginx</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M189.94,-24.44C189.94,-24.44 310.94,-24.44 310.94,-24.44 316.94,-24.44 322.94,-30.44 322.94,-36.44 322.94,-36.44 322.94,-114.44 322.94,-114.44 322.94,-120.44 316.94,-126.44 310.94,-126.44 310.94,-126.44 189.94,-126.44 189.94,-126.44 183.94,-126.44 177.94,-120.44 177.94,-114.44 177.94,-114.44 177.94,-36.44 177.94,-36.44 177.94,-30.44 183.94,-24.44 189.94,-24.44"/>
<text xml:space="preserve" text-anchor="middle" x="250.44" y="-109.14" font-family="Arial" font-size="14.00" fill="#616e7c">Nginx (reverse proxy)</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_app</title>
<path fill="#f5f7fa" stroke="#cbd2d9" stroke-dasharray="5,2" d="M434.44,-155.44C434.44,-155.44 525.69,-155.44 525.69,-155.44 531.69,-155.44 537.69,-161.44 537.69,-167.44 537.69,-167.44 537.69,-273.44 537.69,-273.44 537.69,-279.44 531.69,-285.44 525.69,-285.44 525.69,-285.44 434.44,-285.44 434.44,-285.44 428.44,-285.44 422.44,-279.44 422.44,-273.44 422.44,-273.44 422.44,-167.44 422.44,-167.44 422.44,-161.44 428.44,-155.44 434.44,-155.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-268.14" font-family="Arial" font-size="14.00" fill="#616e7c">Managed App</text>
</g>
<g id="clust3" class="cluster accent">
<title>cluster_spr</title>
<path fill="#d6e4ff" stroke="#3a7dff" stroke-dasharray="5,2" d="M449.44,-17.44C449.44,-17.44 714.19,-17.44 714.19,-17.44 720.19,-17.44 726.19,-23.44 726.19,-29.44 726.19,-29.44 726.19,-135.44 726.19,-135.44 726.19,-141.44 720.19,-147.44 714.19,-147.44 714.19,-147.44 449.44,-147.44 449.44,-147.44 443.44,-147.44 437.44,-141.44 437.44,-135.44 437.44,-135.44 437.44,-29.44 437.44,-29.44 437.44,-23.44 443.44,-17.44 449.44,-17.44"/>
<text xml:space="preserve" text-anchor="middle" x="581.82" y="-130.14" font-family="Arial" font-size="14.00" fill="#616e7c">Soleprint</text>
</g>
<!-- proxy -->
<g id="node1" class="node">
<title>proxy</title>
<polygon fill="#ffffff" stroke="#9aa5b1" points="292.07,-94.44 207.82,-94.44 207.82,-90.44 203.82,-90.44 203.82,-86.44 207.82,-86.44 207.82,-40.44 203.82,-40.44 203.82,-36.44 207.82,-36.44 207.82,-32.44 292.07,-32.44 292.07,-94.44"/>
<polyline fill="none" stroke="#9aa5b1" points="207.82,-90.44 211.82,-90.44 211.82,-86.44 207.82,-86.44"/>
<polyline fill="none" stroke="#9aa5b1" points="207.82,-40.44 211.82,-40.44 211.82,-36.44 207.82,-36.44"/>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-79.99" font-family="Arial" font-size="11.00" fill="#1f2933">proxy_pass</text>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-66.49" font-family="Arial" font-size="11.00" fill="#1f2933">+</text>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-52.99" font-family="Arial" font-size="11.00" fill="#1f2933">sub_filter</text>
<text xml:space="preserve" text-anchor="middle" x="249.94" y="-39.49" font-family="Arial" font-size="11.00" fill="#1f2933">injects sidebar</text>
</g>
<!-- frontend -->
<g id="node2" class="node">
<title>frontend</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M517.69,-199.44C517.69,-199.44 442.44,-199.44 442.44,-199.44 436.44,-199.44 430.44,-193.44 430.44,-187.44 430.44,-187.44 430.44,-175.44 430.44,-175.44 430.44,-169.44 436.44,-163.44 442.44,-163.44 442.44,-163.44 517.69,-163.44 517.69,-163.44 523.69,-163.44 529.69,-169.44 529.69,-175.44 529.69,-175.44 529.69,-187.44 529.69,-187.44 529.69,-193.44 523.69,-199.44 517.69,-199.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-184.49" font-family="Arial" font-size="11.00" fill="#1f2933">Frontend</text>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-170.99" font-family="Arial" font-size="11.00" fill="#1f2933">(React/Next/Vue)</text>
</g>
<!-- proxy&#45;&gt;frontend -->
<g id="edge2" class="edge">
<title>proxy&#45;&gt;frontend</title>
<path fill="none" stroke="#9aa5b1" d="M292.52,-84.95C333.17,-105.98 394.79,-137.85 435.96,-159.15"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="434.71,-161.26 442.05,-162.3 436.96,-156.91 434.71,-161.26"/>
<text xml:space="preserve" text-anchor="middle" x="372.69" y="-148.53" font-family="Arial" font-size="9.00" fill="#616e7c">/ → app</text>
</g>
<!-- sidebar_css -->
<g id="node4" class="node">
<title>sidebar_css</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M502.69,-115.44C502.69,-115.44 457.44,-115.44 457.44,-115.44 451.44,-115.44 445.44,-109.44 445.44,-103.44 445.44,-103.44 445.44,-91.44 445.44,-91.44 445.44,-85.44 451.44,-79.44 457.44,-79.44 457.44,-79.44 502.69,-79.44 502.69,-79.44 508.69,-79.44 514.69,-85.44 514.69,-91.44 514.69,-91.44 514.69,-103.44 514.69,-103.44 514.69,-109.44 508.69,-115.44 502.69,-115.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-93.74" font-family="Arial" font-size="11.00" fill="#1f2933">sidebar.css</text>
</g>
<!-- proxy&#45;&gt;sidebar_css -->
<g id="edge3" class="edge accent">
<title>proxy&#45;&gt;sidebar_css</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M292.52,-69.64C333.52,-75.75 395.85,-85.04 437.01,-91.18"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="436.42,-93.56 443.71,-92.17 437.15,-88.72 436.42,-93.56"/>
<text xml:space="preserve" text-anchor="middle" x="372.69" y="-89.35" font-family="Arial" font-size="9.00" fill="#3a7dff">injects into &lt;/head&gt;</text>
</g>
<!-- sidebar_js -->
<g id="node5" class="node">
<title>sidebar_js</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M498.57,-61.44C498.57,-61.44 461.57,-61.44 461.57,-61.44 455.57,-61.44 449.57,-55.44 449.57,-49.44 449.57,-49.44 449.57,-37.44 449.57,-37.44 449.57,-31.44 455.57,-25.44 461.57,-25.44 461.57,-25.44 498.57,-25.44 498.57,-25.44 504.57,-25.44 510.57,-31.44 510.57,-37.44 510.57,-37.44 510.57,-49.44 510.57,-49.44 510.57,-55.44 504.57,-61.44 498.57,-61.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-39.74" font-family="Arial" font-size="11.00" fill="#1f2933">sidebar.js</text>
</g>
<!-- proxy&#45;&gt;sidebar_js -->
<g id="edge4" class="edge accent">
<title>proxy&#45;&gt;sidebar_js</title>
<path fill="none" stroke="#3a7dff" stroke-dasharray="5,2" d="M292.52,-59.8C334.87,-56.08 399.99,-50.38 441.02,-46.78"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="441.13,-49.23 447.89,-46.18 440.7,-44.35 441.13,-49.23"/>
</g>
<!-- hub -->
<g id="node6" class="node">
<title>hub</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M706.19,-61.44C706.19,-61.44 627.19,-61.44 627.19,-61.44 621.19,-61.44 615.19,-55.44 615.19,-49.44 615.19,-49.44 615.19,-37.44 615.19,-37.44 615.19,-31.44 621.19,-25.44 627.19,-25.44 627.19,-25.44 706.19,-25.44 706.19,-25.44 712.19,-25.44 718.19,-31.44 718.19,-37.44 718.19,-37.44 718.19,-49.44 718.19,-49.44 718.19,-55.44 712.19,-61.44 706.19,-61.44"/>
<text xml:space="preserve" text-anchor="middle" x="666.69" y="-46.49" font-family="Arial" font-size="11.00" fill="#1f2933">Hub API</text>
<text xml:space="preserve" text-anchor="middle" x="666.69" y="-32.99" font-family="Arial" font-size="11.00" fill="#1f2933">/api/sidebar/config</text>
</g>
<!-- proxy&#45;&gt;hub -->
<g id="edge5" class="edge">
<title>proxy&#45;&gt;hub</title>
<path fill="none" stroke="#9aa5b1" d="M292.27,-45.37C326.42,-31.48 376.57,-13.43 422.44,-5.94 473,2.31 486.99,1.33 537.69,-5.94 560.76,-9.25 585.47,-15.76 606.97,-22.43"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="606.19,-24.75 613.6,-24.53 607.67,-20.08 606.19,-24.75"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-7.89" font-family="Arial" font-size="9.00" fill="#616e7c">/spr/ → soleprint</text>
</g>
<!-- backend -->
<g id="node3" class="node">
<title>backend</title>
<path fill="#ffffff" stroke="#9aa5b1" d="M507.19,-253.44C507.19,-253.44 452.94,-253.44 452.94,-253.44 446.94,-253.44 440.94,-247.44 440.94,-241.44 440.94,-241.44 440.94,-229.44 440.94,-229.44 440.94,-223.44 446.94,-217.44 452.94,-217.44 452.94,-217.44 507.19,-217.44 507.19,-217.44 513.19,-217.44 519.19,-223.44 519.19,-229.44 519.19,-229.44 519.19,-241.44 519.19,-241.44 519.19,-247.44 513.19,-253.44 507.19,-253.44"/>
<text xml:space="preserve" text-anchor="middle" x="480.07" y="-231.74" font-family="Arial" font-size="11.00" fill="#1f2933">Backend API</text>
</g>
<!-- sidebar_js&#45;&gt;hub -->
<g id="edge6" class="edge accent">
<title>sidebar_js&#45;&gt;hub</title>
<path fill="none" stroke="#3a7dff" d="M510.73,-43.44C536.46,-43.44 574.49,-43.44 606.44,-43.44"/>
<polygon fill="#3a7dff" stroke="#3a7dff" points="606.33,-45.89 613.33,-43.44 606.33,-40.99 606.33,-45.89"/>
<text xml:space="preserve" text-anchor="middle" x="572.44" y="-45.39" font-family="Arial" font-size="9.00" fill="#3a7dff">loads config</text>
</g>
<!-- browser -->
<g id="node7" class="node">
<title>browser</title>
<ellipse fill="#ffffff" stroke="#9aa5b1" cx="35.22" cy="-63.44" rx="35.22" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="35.22" y="-59.74" font-family="Arial" font-size="11.00" fill="#1f2933">Browser</text>
</g>
<!-- browser&#45;&gt;proxy -->
<g id="edge1" class="edge">
<title>browser&#45;&gt;proxy</title>
<path fill="none" stroke="#9aa5b1" d="M70.87,-63.44C105.71,-63.44 159.77,-63.44 199.19,-63.44"/>
<polygon fill="#9aa5b1" stroke="#9aa5b1" points="198.85,-65.89 205.85,-63.44 198.85,-60.99 198.85,-65.89"/>
<text xml:space="preserve" text-anchor="middle" x="128.19" y="-65.39" font-family="Arial" font-size="9.00" fill="#616e7c">myroom.spr.local.ar</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -10,7 +10,7 @@
<title>wrapping</title>
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-325.41 819.55,-325.41 819.55,4 -4,4"/>
<text xml:space="preserve" text-anchor="middle" x="407.78" y="-304.11" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Sidebar Injection — How Wrapping Works</text>
<g id="clust1" class="cluster">
<g id="clust1" class="cluster accent">
<title>cluster_nginx</title>
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="193.3,-25.16 193.3,-128.16 360.05,-128.16 360.05,-25.16 193.3,-25.16"/>
<text xml:space="preserve" text-anchor="middle" x="276.68" y="-110.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Nginx (reverse proxy)</text>
@@ -20,19 +20,13 @@
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="473.05,-157.16 473.05,-288.16 599.55,-288.16 599.55,-157.16 473.05,-157.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-270.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Managed App</text>
</g>
<g id="clust3" class="cluster">
<g id="clust3" class="cluster accent">
<title>cluster_spr</title>
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="489.55,-18.16 489.55,-149.16 807.55,-149.16 807.55,-18.16 489.55,-18.16"/>
<text xml:space="preserve" text-anchor="middle" x="648.55" y="-131.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint</text>
</g>
<!-- browser -->
<g id="node1" class="node">
<title>browser</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="38.03" cy="-64.16" rx="38.03" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="38.03" y="-60.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Browser</text>
</g>
<!-- proxy -->
<g id="node2" class="node">
<g id="node1" class="node">
<title>proxy</title>
<polygon fill="#1a1a1a" stroke="#333333" points="324.3,-95.16 228.05,-95.16 228.05,-91.16 224.05,-91.16 224.05,-87.16 228.05,-87.16 228.05,-41.16 224.05,-41.16 224.05,-37.16 228.05,-37.16 228.05,-33.16 324.3,-33.16 324.3,-95.16"/>
<polyline fill="none" stroke="#333333" points="228.05,-91.16 232.05,-91.16 232.05,-87.16 228.05,-87.16"/>
@@ -42,15 +36,8 @@
<text xml:space="preserve" text-anchor="middle" x="276.18" y="-53.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sub_filter</text>
<text xml:space="preserve" text-anchor="middle" x="276.18" y="-40.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">injects sidebar</text>
</g>
<!-- browser&#45;&gt;proxy -->
<g id="edge1" class="edge">
<title>browser&#45;&gt;proxy</title>
<path fill="none" stroke="#666666" d="M76.45,-64.16C114.05,-64.16 172.57,-64.16 216.38,-64.16"/>
<polygon fill="#666666" stroke="#666666" points="216.24,-67.66 226.24,-64.16 216.24,-60.66 216.24,-67.66"/>
<text xml:space="preserve" text-anchor="middle" x="138.68" y="-66.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">myroom.spr.local.ar</text>
</g>
<!-- frontend -->
<g id="node3" class="node">
<g id="node2" class="node">
<title>frontend</title>
<polygon fill="#1a1a1a" stroke="#333333" points="591.55,-201.16 481.05,-201.16 481.05,-165.16 591.55,-165.16 591.55,-201.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-186.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text>
@@ -64,56 +51,69 @@
<text xml:space="preserve" text-anchor="middle" x="416.55" y="-150.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/ → app</text>
</g>
<!-- sidebar_css -->
<g id="node5" class="node">
<g id="node4" class="node">
<title>sidebar_css</title>
<polygon fill="#1a1a1a" stroke="#333333" points="575.05,-116.16 497.55,-116.16 497.55,-80.16 575.05,-80.16 575.05,-116.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-94.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.css</text>
</g>
<!-- proxy&#45;&gt;sidebar_css -->
<g id="edge4" class="edge">
<g id="edge3" class="edge accent">
<title>proxy&#45;&gt;sidebar_css</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M324.52,-70.4C370.27,-76.43 439.3,-85.52 485.78,-91.64"/>
<polygon fill="#d4a574" stroke="#d4a574" points="485.21,-95.09 495.58,-92.93 486.12,-88.15 485.21,-95.09"/>
<text xml:space="preserve" text-anchor="middle" x="416.55" y="-90.82" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">injects into &lt;/head&gt;</text>
<text xml:space="preserve" text-anchor="middle" x="416.55" y="-90.82" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">injects into &lt;/head&gt;</text>
</g>
<!-- sidebar_js -->
<g id="node6" class="node">
<g id="node5" class="node">
<title>sidebar_js</title>
<polygon fill="#1a1a1a" stroke="#333333" points="570.55,-62.16 502.05,-62.16 502.05,-26.16 570.55,-26.16 570.55,-62.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-40.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.js</text>
</g>
<!-- proxy&#45;&gt;sidebar_js -->
<g id="edge5" class="edge">
<g id="edge4" class="edge accent">
<title>proxy&#45;&gt;sidebar_js</title>
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M324.52,-60.49C371.88,-56.82 444.19,-51.22 490.6,-47.63"/>
<polygon fill="#d4a574" stroke="#d4a574" points="490.6,-51.14 500.3,-46.87 490.06,-44.16 490.6,-51.14"/>
</g>
<!-- hub -->
<g id="node7" class="node">
<g id="node6" class="node">
<title>hub</title>
<polygon fill="#1a1a1a" stroke="#333333" points="799.55,-62.16 682.3,-62.16 682.3,-26.16 799.55,-26.16 799.55,-62.16"/>
<text xml:space="preserve" text-anchor="middle" x="740.93" y="-47.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Hub API</text>
<text xml:space="preserve" text-anchor="middle" x="740.93" y="-33.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">/api/sidebar/config</text>
</g>
<!-- proxy&#45;&gt;hub -->
<g id="edge3" class="edge">
<g id="edge5" class="edge">
<title>proxy&#45;&gt;hub</title>
<path fill="none" stroke="#666666" d="M324.6,-45.85C363.74,-31.76 421.09,-13.48 473.05,-5.91 528.69,2.18 543.83,1.54 599.55,-5.91 624.52,-9.25 651.33,-15.79 674.76,-22.54"/>
<polygon fill="#666666" stroke="#666666" points="673.59,-25.84 684.17,-25.31 675.57,-19.12 673.59,-25.84"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-8.61" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/spr/ → soleprint</text>
</g>
<!-- backend -->
<g id="node4" class="node">
<g id="node3" class="node">
<title>backend</title>
<polygon fill="#1a1a1a" stroke="#333333" points="578.05,-255.16 494.55,-255.16 494.55,-219.16 578.05,-219.16 578.05,-255.16"/>
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-233.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend API</text>
</g>
<!-- sidebar_js&#45;&gt;hub -->
<g id="edge6" class="edge">
<g id="edge6" class="edge accent">
<title>sidebar_js&#45;&gt;hub</title>
<path fill="none" stroke="#d4a574" d="M570.76,-44.16C597.83,-44.16 636.85,-44.16 670.6,-44.16"/>
<polygon fill="#d4a574" stroke="#d4a574" points="670.3,-47.66 680.3,-44.16 670.3,-40.66 670.3,-47.66"/>
<text xml:space="preserve" text-anchor="middle" x="636.93" y="-46.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">loads config</text>
<text xml:space="preserve" text-anchor="middle" x="636.93" y="-46.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">loads config</text>
</g>
<!-- browser -->
<g id="node7" class="node">
<title>browser</title>
<ellipse fill="#1a1a1a" stroke="#333333" cx="38.03" cy="-64.16" rx="38.03" ry="18"/>
<text xml:space="preserve" text-anchor="middle" x="38.03" y="-60.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Browser</text>
</g>
<!-- browser&#45;&gt;proxy -->
<g id="edge1" class="edge">
<title>browser&#45;&gt;proxy</title>
<path fill="none" stroke="#666666" d="M76.45,-64.16C114.05,-64.16 172.57,-64.16 216.38,-64.16"/>
<polygon fill="#666666" stroke="#666666" points="216.24,-67.66 226.24,-64.16 216.24,-60.66 216.24,-67.66"/>
<text xml:space="preserve" text-anchor="middle" x="138.68" y="-66.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">myroom.spr.local.ar</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -1,6 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Pawprint Models",
"title": "Soleprint Models",
"description": "Platform-agnostic model definitions. Portable to TypeScript, Pydantic, Django, Prisma.",
"definitions": {
"Status": {

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,7 +14,7 @@
box-sizing: border-box;
}
html {
background: #0a0a0a;
background: var(--bg);
}
body {
font-family:
@@ -25,8 +25,8 @@
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
background: #b91c1c;
color: var(--text);
background: var(--system-accent);
}
header {
display: flex;
@@ -51,7 +51,7 @@
padding-bottom: 2rem;
}
section {
background: #0a0a0a;
background: var(--bg);
padding: 1.5rem;
margin: 1.5rem 0;
border-radius: 12px;
@@ -59,23 +59,23 @@
section h2 {
margin: 0 0 1rem 0;
font-size: 1.2rem;
color: #fca5a5;
color: var(--system-accent-text);
}
.composition {
background: #1a1a1a;
border: 2px solid #b91c1c;
background: var(--surface);
border: 2px solid var(--system-accent);
padding: 1rem;
border-radius: 12px;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #fca5a5;
color: var(--system-accent-text);
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
color: var(--muted);
}
.components {
display: grid;
@@ -83,20 +83,20 @@
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
background: var(--bg);
border: 1px solid var(--border-strong);
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #fca5a5;
color: var(--system-accent-text);
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
color: var(--muted);
}
.veins {
display: grid;
@@ -104,8 +104,8 @@
gap: 1rem;
}
.vein {
background: #1a1a1a;
border: 1px solid #3f3f3f;
background: var(--surface);
border: 1px solid var(--border-strong);
padding: 1rem;
border-radius: 8px;
text-align: center;
@@ -113,16 +113,16 @@
transition: all 0.15s;
}
.vein:hover {
background: #2a2a2a;
background: var(--border);
}
.vein.selected {
border-color: #b91c1c;
border-color: var(--system-accent);
border-width: 2px;
background: #1a1a1a;
background: var(--surface);
}
.vein.active {
background: #b91c1c;
border-color: #b91c1c;
background: var(--system-accent);
border-color: var(--system-accent);
}
.vein.active h3 {
color: white;
@@ -139,12 +139,12 @@
cursor: not-allowed;
}
.vein.disabled:hover {
background: #1a1a1a;
background: var(--surface);
}
.vein h3 {
margin: 0;
font-size: 1rem;
color: #e5e5e5;
color: var(--text);
}
.endpoints {
list-style: none;
@@ -153,7 +153,7 @@
}
.endpoints li {
padding: 0.75rem 0;
border-bottom: 1px solid #3f3f3f;
border-bottom: 1px solid var(--border-strong);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
@@ -164,18 +164,18 @@
}
.endpoints code {
font-family: monospace;
background: #1a1a1a;
background: var(--surface);
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: #fca5a5;
color: var(--system-accent-text);
}
.endpoints .desc {
color: #a3a3a3;
color: var(--muted);
font-size: 0.9rem;
}
code {
background: #2a2a2a;
color: #fca5a5;
background: var(--border);
color: var(--system-accent-text);
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.85rem;
@@ -210,41 +210,41 @@
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: #e5e5e5;
color: var(--text);
}
.api-form input[type="text"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #3f3f3f;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-family: monospace;
font-size: 0.9rem;
background: #1a1a1a;
color: #e5e5e5;
background: var(--surface);
color: var(--text);
}
.api-form input[type="text"]:focus,
.api-form select:focus {
outline: none;
border-color: #b91c1c;
border-color: var(--system-accent);
}
.api-form select {
width: 100%;
padding: 0.75rem;
border: 1px solid #3f3f3f;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.9rem;
background: #1a1a1a;
color: #e5e5e5;
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.api-form select:disabled {
background: #0a0a0a;
color: #666;
background: var(--bg);
color: var(--dim);
cursor: not-allowed;
}
.api-form input:disabled {
background: #0a0a0a;
color: #666;
background: var(--bg);
color: var(--dim);
cursor: not-allowed;
}
.api-controls {
@@ -254,7 +254,7 @@
margin-top: 1rem;
}
.api-controls button {
background: #b91c1c;
background: var(--system-accent);
color: white;
border: none;
padding: 0.75rem 1.5rem;
@@ -271,17 +271,17 @@
cursor: not-allowed;
}
.tab-button {
background: #1a1a1a !important;
border: 1px solid #3f3f3f !important;
color: #e5e5e5 !important;
background: var(--surface) !important;
border: 1px solid var(--border-strong) !important;
color: var(--text) !important;
}
.tab-button:hover {
background: #2a2a2a !important;
background: var(--border) !important;
}
.tab-button.active {
border-color: white !important;
border-width: 2px !important;
background: #b91c1c !important;
background: var(--system-accent) !important;
color: white !important;
}
.tab-button.active:hover {
@@ -291,19 +291,19 @@
font-size: 2rem;
font-weight: bold;
padding: 2rem;
color: #e5e5e5;
color: var(--text);
animation: pulse 2s ease-in-out infinite;
}
.epic-status.error {
color: #fca5a5;
color: var(--system-accent-text);
}
@keyframes pulse {
0%,
100% {
color: #0a0a0a;
color: var(--bg);
}
50% {
color: #b91c1c;
color: var(--system-accent);
}
}
.api-controls label {
@@ -312,7 +312,7 @@
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
color: #e5e5e5;
color: var(--text);
}
.output-container {
position: relative;
@@ -324,8 +324,8 @@
}
.output-area {
background: #1a1a1a;
color: #e5e5e5;
background: var(--surface);
color: var(--text);
padding: 1rem;
padding-top: 2.5rem;
border-radius: 8px;
@@ -337,7 +337,7 @@
position: relative;
}
.output-area.error {
color: #fca5a5;
color: var(--system-accent-text);
}
.output-area.scrollable {
max-height: 1000px;
@@ -353,7 +353,7 @@
margin-top: 1.5rem;
}
.attachments-container h3 {
color: #fca5a5;
color: var(--system-accent-text);
margin: 0 0 1rem 0;
font-size: 1rem;
}
@@ -370,10 +370,30 @@
}
.attachment-label {
font-size: 0.85rem;
color: #a3a3a3;
color: var(--muted);
margin-bottom: 0.5rem;
}
</style>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--border: #2e2e38;
--border-strong: #3d3d4a;
--dim: #555568;
--muted: #8888a0;
--surface: #16161a;
--system-accent: #d4a574;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Artery keeps its own colour under every theme. */
:root { --system-accent: #b91c1c; --system-accent-text: #fca5a5; }
</style>
</head>
<body>
<header style="position: relative">
@@ -393,8 +413,8 @@
<circle cx="24" cy="20" r="2" fill="currentColor" />
</svg>
<h1>Artery</h1>
{% if pawprint_url %}<a
href="{{ pawprint_url }}"
{% if soleprint_url %}<a
href="{{ soleprint_url }}"
style="
position: absolute;
right: 0;
@@ -913,7 +933,7 @@
</section>
<footer>
{% if pawprint_url %}<a href="{{ pawprint_url }}">← Soleprint</a>{%
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a>{%
else %}<span class="disabled">← Soleprint</span>{% endif %}
</footer>
@@ -1824,5 +1844,6 @@
}
});
</script>
<script src="/theme.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,43 @@
{
"name": "bundle",
"title": "Soleprint Bundle",
"description": "What a rig installation has at its disposal. Exports to a single file that opens with no server.",
"theme": "lucid",
"graph": "system_overview",
"_comment": "A plexus is exported, not served. build.py inlines the theme, the data and the diagram into one index.html so it survives a locked-down Windows box, a zip attachment and a double-click. Nothing here is fetched at runtime.",
"tools": [
{"name": "modelgen", "summary": "Generate models from 6 sources to 9 targets", "standalone": true},
{"name": "datagen", "summary": "Serve rig-owned generators; seed from real rows", "standalone": true},
{"name": "graphgen", "summary": "Generate navigable model graphs", "standalone": true},
{"name": "shuntgen", "summary": "OpenAPI spec or CSV/ODS folder to a running fake service", "standalone": true},
{"name": "tester", "summary": "HTTP contract test runner — one suite, any environment", "standalone": true},
{"name": "databrowse", "summary": "SQL data browser", "standalone": true},
{"name": "sbwrapper", "summary": "Sandbox wrapper", "standalone": true}
],
"cabinets": [
{"name": "postgres", "summary": "Relational database", "rig_addon": "postgres"},
{"name": "redis", "summary": "Cache and broker", "rig_addon": "redis"},
{"name": "airflow", "summary": "Scheduled pipelines", "rig_addon": "airflow", "needs": ["postgres", "redis"]}
],
"veins": [
{"name": "google", "summary": "Sheets and Drive, over OAuth2"},
{"name": "jira", "summary": "Issues and boards"},
{"name": "slack", "summary": "Messages and channels"},
{"name": "ia", "summary": "Model connector"}
],
"themes": [
{"name": "lucid", "summary": "Light, print-ready, shaped after lucid.app", "active": true},
{"name": "soleprint", "summary": "Dark, rounded, amber — the default"},
{"name": "mcrn", "summary": "Dark, square, monospace"}
],
"next": [
"Every tool above is standalone: it runs without the rest of soleprint.",
"Cabinets install as compose services on a laptop, or as rig addons of the same name in a cluster.",
"The diagram below is generated from docs/graphs/*.dot and themed by the same palette as this page."
]
}

View File

@@ -7,15 +7,8 @@ from fastapi.responses import JSONResponse
from typing import Optional, List, Dict, Any
from pydantic import BaseModel
# Import datagen from ward/tools
import sys
from pathlib import Path
ward_tools_path = Path(__file__).parent.parent.parent.parent.parent / "ward" / "tools"
sys.path.insert(0, str(ward_tools_path))
from datagen.mercadopago import MercadoPagoDataGenerator
from ..core.config import settings
from core.config import settings
from datagen import MercadoPagoDataGenerator
router = APIRouter()

View File

@@ -0,0 +1,182 @@
"""
MercadoPago response shapes.
This lived under ward/tools/datagen/ before that tree was renamed to station/,
and the module was lost in the move — api/routes.py has been importing a path
that does not exist since. It belongs here rather than back in station/tools:
a shunt runs as its own process, and its payload shapes are part of it.
Amounts are in whole currency units, matching what the real API returns for
ARS. Every method is static; the routes hold the state, this holds the shapes.
"""
import random
import uuid
from datetime import datetime, timedelta, timezone
CURRENCY = "ARS"
SITE = "MLA" # MercadoPago Argentina
PAYMENT_METHODS = ["visa", "master", "amex", "account_money", "rapipago"]
PAYMENT_TYPES = ["credit_card", "debit_card", "account_money", "ticket"]
STATUS_DETAIL = {
"approved": "accredited",
"pending": "pending_contingency",
"in_process": "pending_review_manual",
"rejected": "cc_rejected_insufficient_amount",
"cancelled": "expired",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _later(minutes: int) -> str:
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat()
def _numeric_id() -> int:
"""A MercadoPago-sized numeric id — the routes look these up as ints."""
return random.randint(1_000_000_000, 9_999_999_999)
class MercadoPagoDataGenerator:
"""Builds MercadoPago-shaped payloads for the shunt to hand back."""
@staticmethod
def preference(
description: str = "Payment",
total: float = 0.0,
external_reference: str | None = None,
) -> dict:
preference_id = f"{random.randint(100000000, 999999999)}-{uuid.uuid4()}"
return {
"id": preference_id,
"client_id": str(random.randint(1_000_000_000_000_000, 9_999_999_999_999_999)),
"collector_id": _numeric_id(),
"date_created": _now(),
"expires": False,
"external_reference": external_reference or "",
"init_point": f"https://www.mercadopago.com.ar/checkout/v1/redirect?pref_id={preference_id}",
"sandbox_init_point": (
f"https://sandbox.mercadopago.com.ar/checkout/v1/redirect?pref_id={preference_id}"
),
"items": [
{
"id": str(uuid.uuid4()),
"title": description,
"description": description,
"quantity": 1,
"unit_price": total,
"currency_id": CURRENCY,
}
],
"marketplace": "NONE",
"marketplace_fee": 0,
"operation_type": "regular_payment",
"site_id": SITE,
"total_amount": total,
}
@staticmethod
def payment(
transaction_amount: float = 0.0,
description: str = "Payment",
status: str = "approved",
application_fee: float | None = None,
) -> dict:
method = random.choice(PAYMENT_METHODS)
fee = round(transaction_amount * 0.0579, 2) if transaction_amount else 0.0
return {
"id": _numeric_id(),
"date_created": _now(),
"date_approved": _now() if status == "approved" else None,
"date_last_updated": _now(),
"money_release_date": _later(60 * 24 * 14) if status == "approved" else None,
"operation_type": "regular_payment",
"payment_method_id": method,
"payment_type_id": random.choice(PAYMENT_TYPES),
"status": status,
"status_detail": STATUS_DETAIL.get(status, "accredited"),
"currency_id": CURRENCY,
"description": description,
"live_mode": False,
"collector_id": _numeric_id(),
"payer": {
"id": str(_numeric_id()),
"email": f"test_user_{random.randint(1000, 99999)}@testuser.com",
"identification": {"type": "DNI", "number": str(random.randint(10_000_000, 45_000_000))},
"type": "customer",
},
"transaction_amount": transaction_amount,
"transaction_amount_refunded": 0,
"installments": 1,
"transaction_details": {
"net_received_amount": (
round(transaction_amount - fee, 2) if status == "approved" else 0
),
"total_paid_amount": transaction_amount,
"overpaid_amount": 0,
"installment_amount": transaction_amount,
},
"fee_details": (
[{"type": "mercadopago_fee", "amount": fee, "fee_payer": "collector"}]
if status == "approved"
else []
),
"application_fee": application_fee,
"captured": status == "approved",
"external_reference": None,
}
@staticmethod
def merchant_order(
preference_id: str = "",
total: float = 0.0,
paid_amount: float = 0.0,
) -> dict:
return {
"id": _numeric_id(),
"status": "closed" if paid_amount >= total and total else "opened",
"external_reference": "",
"preference_id": preference_id,
"payments": [],
"shipments": [],
"date_created": _now(),
"last_updated": _now(),
"site_id": SITE,
"total_amount": total,
"paid_amount": paid_amount,
"refunded_amount": 0,
"order_status": "paid" if paid_amount >= total and total else "payment_required",
}
@staticmethod
def oauth_token() -> dict:
return {
"access_token": f"APP_USR-{uuid.uuid4().hex}",
"token_type": "Bearer",
"expires_in": 15552000,
"scope": "offline_access read write",
"user_id": _numeric_id(),
"refresh_token": f"TG-{uuid.uuid4().hex}",
"public_key": f"APP_USR-{uuid.uuid4()}",
"live_mode": False,
}
@staticmethod
def webhook_notification(topic: str = "payment", resource_id: str = "") -> dict:
return {
"id": _numeric_id(),
"live_mode": False,
"type": topic,
"date_created": _now(),
"application_id": _numeric_id(),
"user_id": _numeric_id(),
"version": 1,
"api_version": "v1",
"action": f"{topic}.updated",
"data": {"id": str(resource_id)},
}

View File

@@ -5,8 +5,10 @@ from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from .api.routes import router
from .core.config import settings
# Absolute imports: a shunt is started from its own directory (`python run.py`),
# not imported as a package, so relative imports have no parent to resolve.
from api.routes import router
from core.config import settings
app = FastAPI(
title="MercadoPago (MOCK)",

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,7 +14,7 @@
box-sizing: border-box;
}
html {
background: #0a0a0a;
background: var(--bg);
}
body {
font-family:
@@ -25,7 +25,7 @@
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
color: var(--text);
background: #163528;
}
header {
@@ -59,7 +59,7 @@
section h2 {
margin: 0 0 1rem 0;
font-size: 1.2rem;
color: #86efac;
color: var(--system-accent-text);
}
.composition {
background: #000000;
@@ -70,12 +70,12 @@
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #86efac;
color: var(--system-accent-text);
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
color: var(--muted);
}
.components {
display: grid;
@@ -83,7 +83,7 @@
gap: 0.75rem;
}
.component {
background: #0a0a0a;
background: var(--bg);
border: 1px solid #6b665e;
padding: 0.75rem;
border-radius: 8px;
@@ -91,12 +91,12 @@
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #86efac;
color: var(--system-accent-text);
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
color: var(--muted);
}
.books {
list-style: none;
@@ -114,7 +114,7 @@
border-bottom: none;
}
.books a {
color: #86efac;
color: var(--system-accent-text);
text-decoration: none;
font-weight: 500;
}
@@ -138,6 +138,21 @@
opacity: 0.5;
}
</style>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--muted: #8888a0;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Atlas keeps its own colour under every theme. */
:root { --system-accent: #15803d; --system-accent-text: #86efac; }
</style>
</head>
<body>
<header style="position: relative">
@@ -265,5 +280,6 @@
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
</footer>
<script src="/theme.js" defer></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
"""
Album - Documentation system.
Atlas - Documentation system.
"""
import os
@@ -11,7 +11,7 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI(title="Album", version="0.1.0")
app = FastAPI(title="Atlas", version="0.1.0")
BASE_DIR = Path(__file__).parent.resolve()
BOOK_DIR = BASE_DIR / "book"
@@ -25,24 +25,24 @@ templates = Jinja2Templates(directory=str(BASE_DIR))
# Serve static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Pawprint URL for data fetching
PAWPRINT_URL = os.getenv("PAWPRINT_URL", "http://localhost:12000")
# The soleprint hub this atlas reads its data from.
SOLEPRINT_URL = os.getenv("SOLEPRINT_URL", "http://localhost:12000")
def get_data():
"""Fetch data from pawprint hub."""
"""Fetch data from the soleprint hub."""
try:
resp = httpx.get(f"{PAWPRINT_URL}/api/data/album", timeout=5.0)
resp = httpx.get(f"{SOLEPRINT_URL}/api/data/atlas", timeout=5.0)
if resp.status_code == 200:
return resp.json()
except Exception as e:
print(f"Failed to fetch data from pawprint: {e}")
return {"templates": [], "larders": [], "books": []}
print(f"Failed to fetch data from soleprint: {e}")
return {"templates": [], "depots": [], "books": []}
@app.get("/health")
def health():
return {"status": "ok", "service": "album"}
return {"status": "ok", "service": "atlas"}
@app.get("/")
@@ -52,7 +52,7 @@ def index(request: Request):
"index.html",
{
"request": request,
"pawprint_url": os.getenv("PAWPRINT_EXTERNAL_URL", PAWPRINT_URL),
"soleprint_url": os.getenv("SOLEPRINT_EXTERNAL_URL", SOLEPRINT_URL),
**data,
},
)
@@ -60,7 +60,7 @@ def index(request: Request):
@app.get("/api/data")
def api_data():
"""API endpoint for frontend data (proxied from pawprint)."""
"""API endpoint for frontend data (proxied from soleprint)."""
return get_data()
@@ -130,7 +130,7 @@ def feature_form_samples_template():
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feature Form Template · Album</title>
<title>Feature Form Template · Atlas</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
@@ -229,7 +229,7 @@ def feature_form_samples_template():
<div class="container">
<header>
<div class="breadcrumb">
<a href="/">Album</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
<a href="/">Atlas</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
</div>
<h1>Feature Form Template</h1>
<div class="meta">
@@ -244,7 +244,7 @@ def feature_form_samples_template():
<div class="form-body">
<div class="field">
<label class="field-label">Tipo de Usuario</label>
<div class="field-value">[Dueno de mascota / Veterinario / Admin]</div>
<div class="field-value">[Tipo A / Tipo B / Admin]</div>
</div>
<div class="field">
<label class="field-label">Punto de Entrada</label>
@@ -296,25 +296,25 @@ def feature_form_samples_template():
return HTMLResponse(html)
@app.get("/book/feature-form-samples/larder/", response_class=HTMLResponse)
@app.get("/book/feature-form-samples/larder", response_class=HTMLResponse)
def feature_form_samples_larder():
"""Browse the larder (actual data)"""
@app.get("/book/feature-form-samples/depot/", response_class=HTMLResponse)
@app.get("/book/feature-form-samples/depot", response_class=HTMLResponse)
def feature_form_samples_depot():
"""Browse the depot (actual data)"""
html_file = BOOK_DIR / "feature-form-samples" / "index.html"
if html_file.exists():
return HTMLResponse(html_file.read_text())
return HTMLResponse("<h1>Larder index not found</h1>", status_code=404)
return HTMLResponse("<h1>Depot index not found</h1>", status_code=404)
@app.get(
"/book/feature-form-samples/larder/{user_type}/{filename}",
"/book/feature-form-samples/depot/{user_type}/{filename}",
response_class=HTMLResponse,
)
def feature_form_samples_detail(request: Request, user_type: str, filename: str):
"""View a specific feature form"""
# Look in the larder subfolder (feature-form)
larder_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
file_path = larder_dir / user_type / filename
# Look in the depot subfolder (feature-form)
depot_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
file_path = depot_dir / user_type / filename
if not file_path.exists():
return HTMLResponse("<h1>Not found</h1>", status_code=404)

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""
Bake the default palette into the pages that use it.
python3 common/theme/bake.py # rewrite the baked blocks
python3 common/theme/bake.py --check # fail if any page is stale
A page that says `background: var(--bg)` and never gets `--bg` does not fall
back to something plainer — the declaration is invalid at computed-value time,
so the background goes transparent and the text goes initial-black on a design
that assumed dark. Unstyled, not merely unbranded.
That matters because `/theme.css` is an absolute path and soleprint is not
always at the root. In the sample room's nginx, soleprint sits under `/spr/`
while `location /` proxies to the frontend, so `/theme.css` reaches the wrong
service. Same story for a page opened over file://.
So every page carries a baked default: a `:root` block with literal values,
emitted BEFORE the `<link>`. Both are `:root`, so document order decides — the
served stylesheet wins whenever it loads, and the baked block is what is left
when it does not. Nothing is given up in either direction.
The block is generated rather than hand-written, which is the point: the values
come from tokens.css and the default theme, so there is still one source. Only
the variables a page actually uses are emitted, so the blocks stay small.
"""
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SPR_ROOT = HERE.parent.parent # soleprint/
TOKENS = HERE / "tokens.css"
DEFAULT_THEME = HERE / "themes" / "soleprint.css"
BEGIN = "<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->"
END = "<!-- /theme:baked-defaults -->"
# Pages that link /theme.css and therefore need a default to fall back on.
PAGES = [
"index.html",
"artery/index.html",
"atlas/index.html",
"station/index.html",
"station/tools/datagen/templates/index.html",
"station/tools/graphgen/templates/index.html",
"station/tools/shuntgen/templates/index.html",
]
def declarations(css: str, selector: str) -> dict[str, str]:
"""Pull `--name: value;` pairs out of one rule."""
match = re.search(re.escape(selector) + r"\s*\{(.*?)\n\}", css, re.S)
if not match:
return {}
out = {}
for name, value in re.findall(r"(--[\w-]+)\s*:\s*([^;]+);", match.group(1)):
out[name] = value.strip()
return out
def resolve(name: str, table: dict[str, str], seen: frozenset = frozenset()) -> str | None:
"""Flatten a value to literals, following var() chains and honouring fallbacks."""
if name in seen or name not in table:
return None
value = table[name]
def swap(match: re.Match) -> str:
inner = match.group(1)
# var(--x, fallback) — the fallback may itself contain commas.
if "," in inner:
ref, fallback = inner.split(",", 1)
ref, fallback = ref.strip(), fallback.strip()
else:
ref, fallback = inner.strip(), None
resolved = resolve(ref, table, seen | {name})
if resolved is not None:
return resolved
return fallback if fallback is not None else ""
for _ in range(10): # chains are shallow; the bound just stops a cycle
new = re.sub(r"var\(\s*([^()]*(?:\([^()]*\)[^()]*)*)\)", swap, value)
if new == value:
break
value = new
return value.strip() or None
def palette() -> dict[str, str]:
"""Every token, flattened to literals, with the default theme applied."""
tokens = declarations(TOKENS.read_text(), ":root")
tokens.update(declarations(DEFAULT_THEME.read_text(), '[data-theme="soleprint"]'))
return {name: resolve(name, tokens) for name in tokens}
def used(html: str) -> set[str]:
"""Variables a page references, ignoring the baked block itself."""
body = re.sub(re.escape(BEGIN) + r".*?" + re.escape(END), "", html, flags=re.S)
return set(re.findall(r"var\(\s*(--[\w-]+)", body))
def block(names: set[str], values: dict[str, str], indent: str) -> str:
"""The baked <style>, wrapped in markers so it can be replaced next time."""
lines = [f"{indent}{BEGIN}", f"{indent}<style>", f"{indent} :root {{"]
for name in sorted(names):
value = values.get(name)
if value:
lines.append(f"{indent} {name}: {value};")
lines += [f"{indent} }}", f"{indent}</style>", f"{indent}{END}"]
return "\n".join(lines)
def bake(path: Path, values: dict[str, str]) -> tuple[bool, str]:
"""Return (changed, note) for one page."""
html = path.read_text()
link = re.search(r'([ \t]*)<link rel="stylesheet" href="/theme.css">', html)
if not link:
return False, "no /theme.css link — skipped"
indent = link.group(1)
names = used(html)
if not names:
return False, "uses no theme variables — skipped"
fresh = block(names, values, indent)
existing = re.search(re.escape(BEGIN) + r".*?" + re.escape(END), html, re.S)
if existing:
updated = html[: existing.start()] + fresh.lstrip() + html[existing.end() :]
else:
# Before the link, never after: document order is what makes the served
# stylesheet win over the baked one.
updated = html[: link.start()] + fresh + "\n" + html[link.start() :]
if updated == html:
return False, f"up to date ({len(names)} variables)"
path.write_text(updated)
return True, f"baked {len(names)} variables"
def main() -> int:
check = "--check" in sys.argv
values = palette()
missing = [n for n, v in values.items() if not v]
if missing:
print(f"warning: unresolved tokens: {', '.join(sorted(missing))}", file=sys.stderr)
stale = []
for rel in PAGES:
path = SPR_ROOT / rel
if not path.exists():
print(f" {rel}: not found")
continue
if check:
before = path.read_text()
changed, note = bake(path, values)
if changed:
path.write_text(before)
stale.append(rel)
print(f" {rel}: STALE")
else:
print(f" {rel}: {note}")
else:
_, note = bake(path, values)
print(f" {rel}: {note}")
if check and stale:
print(f"\n{len(stale)} page(s) stale — run: python3 common/theme/bake.py", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,122 @@
/* Theme selection, and the toggle that drives it.
*
* Served by run.py at /theme.js, next to /theme.css. Include both and a page
* is themed; there is nothing else to wire.
*
* <link rel="stylesheet" href="/theme.css">
* <script src="/theme.js" defer></script>
*
* Resolution order, strongest first:
* 1. ?theme=mcrn — a link that carries its own theme
* 2. localStorage — what this browser last chose
* 3. <html data-theme> — what the page was served with
* 4. the server default — framework.theme in cfg/config.json
*
* The attribute is set before first paint when this script is loaded in the
* head; with `defer` the page renders once in the served theme and then
* switches, which is why run.py stamps data-theme into the served HTML.
*/
(function () {
"use strict";
// Order is the toggle order. soleprint stays first because it is the
// default; lucid is last because it is the one you switch to on purpose,
// to show someone something.
var THEMES = ["soleprint", "mcrn", "lucid"];
var LABELS = { soleprint: "SPR", mcrn: "MCRN", lucid: "LUCID" };
var KEY = "spr-theme";
var root = document.documentElement;
function fromQuery() {
var match = /[?&]theme=([^&#]+)/.exec(window.location.search);
return match ? decodeURIComponent(match[1]) : null;
}
function stored() {
try {
return window.localStorage.getItem(KEY);
} catch (e) {
// Private mode and file:// origins throw on access rather than
// returning null, and a theme is not worth breaking a page over.
return null;
}
}
function resolve() {
var candidates = [fromQuery(), stored(), root.getAttribute("data-theme")];
for (var i = 0; i < candidates.length; i++) {
if (candidates[i] && THEMES.indexOf(candidates[i]) !== -1) {
return candidates[i];
}
}
return THEMES[0];
}
function apply(theme, persist) {
root.setAttribute("data-theme", theme);
if (persist) {
try {
window.localStorage.setItem(KEY, theme);
} catch (e) {
/* see stored() */
}
}
var buttons = document.querySelectorAll("#spr-theme-toggle button");
for (var i = 0; i < buttons.length; i++) {
buttons[i].setAttribute(
"aria-pressed",
buttons[i].dataset.theme === theme ? "true" : "false"
);
}
window.dispatchEvent(new CustomEvent("spr:theme", { detail: theme }));
}
function buildToggle() {
if (document.getElementById("spr-theme-toggle")) return;
// Opt out with <body data-theme-toggle="off"> — the shunt config UIs
// and any embedded view want the theme without the chrome.
if (document.body.dataset.themeToggle === "off") return;
var box = document.createElement("div");
box.id = "spr-theme-toggle";
box.setAttribute("role", "group");
box.setAttribute("aria-label", "Theme");
THEMES.forEach(function (theme) {
var button = document.createElement("button");
button.type = "button";
button.dataset.theme = theme;
button.textContent = LABELS[theme] || theme.toUpperCase();
button.title = "Switch to the " + theme + " theme";
button.addEventListener("click", function () {
apply(theme, true);
});
box.appendChild(button);
});
document.body.appendChild(box);
}
// Set the attribute immediately; the toggle needs a body to attach to.
apply(resolve(), false);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
buildToggle();
apply(root.getAttribute("data-theme"), false);
});
} else {
buildToggle();
apply(root.getAttribute("data-theme"), false);
}
window.sprTheme = {
get: function () {
return root.getAttribute("data-theme");
},
set: function (theme) {
if (THEMES.indexOf(theme) !== -1) apply(theme, true);
},
themes: THEMES.slice(),
};
})();

View File

@@ -0,0 +1,150 @@
/* Lucid — the regulated-document look, shaped after lucid.app exports.
*
* Why it exists: the deliverable gets shown on Windows, printed, and pasted next
* to real Lucidchart diagrams. Dark developer chrome cannot go in that room. The
* target is that a generated page and a genuine Lucid export sit side by side
* without announcing which is which.
*
* This is the first LIGHT theme here, and that is the part that needed care —
* tokens.css and both sibling themes were written assuming near-black. Two
* things do not survive the inversion and are overridden below rather than
* inherited:
*
* - the glow. A coloured halo means "lit" against black; against white it just
* looks like a rendering fault. Replaced with a hairline drop shadow.
* - --dim as body-adjacent text. At #555568 on white it fails contrast, so the
* ramp is rebuilt from the light end rather than reused.
*
* Fonts are stacks, never a webfont: this has to render with no egress. Arial is
* last because it is the one face guaranteed on Windows and aliased on Linux —
* the same reason the graphviz themes name it (see docs/graphs/themes/).
*/
[data-theme="lucid"] {
color-scheme: light;
--bg: #ffffff;
--bg-2: #f5f7fa;
--surface: #f5f7fa;
--surface-raised: #e4e7eb;
--border: #cbd2d9;
--border-strong: #9aa5b1;
/* Measured against both #ffffff and the #f5f7fa panel, because --dim is used
* for 11px notes and --status-warn for 10px labels — sizes where AA wants
* 4.5:1, not the 3:1 that large text gets away with. The obvious lighter
* greys (#7b8794, #73808d) come in at 3.43.8 on the panel and were dropped
* for that reason. */
--text: #1f2933; /* 14.76 on white — near-black; pure #000 reads harsh in print */
--muted: #616e7c; /* 5.21 / 4.86 */
--dim: #66717d; /* 4.97 / 4.63 */
--accent: #3a7dff;
--accent-dim: #2f6ae0;
--accent-text: #1c5bd9; /* darkened: the fill blue is too light for small text */
--glow: rgba(58, 125, 255, 0.18);
--status-ok: #0b875b; /* 4.53 / 4.23 */
--status-info: #1c5bd9; /* 5.92 / 5.52 */
--status-warn: #a35f00; /* 5.01 / 4.67 — #b06a00 was 3.99 on the panel */
--status-error: #cf2e2e; /* 5.14 / 4.79 */
--status-idle: #9aa5b1; /* dots and rules only, never text */
--radius-sm: 4px;
--radius: 6px;
--radius-lg: 8px;
--radius-xl: 8px;
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", Consolas, "JetBrains Mono", monospace;
--font-heading: "Segoe UI", Inter, system-ui, Arial, sans-serif;
--heading-transform: none;
--heading-spacing: 0;
--heading-weight: 600;
--label-spacing: 0.02em;
--speed-fast: 0.12s;
--speed: 0.18s;
/* Elevation, not luminosity. */
--hover-shadow: 0 1px 3px rgba(16, 24, 40, 0.1), 0 1px 2px rgba(16, 24, 40, 0.06);
--hover-lift: none;
--focus-shadow: 0 0 0 2px rgba(58, 125, 255, 0.35);
}
/* Panels are white cards on a pale canvas — the inverse of the dark themes,
* where the panel is lighter than the page. */
[data-theme="lucid"] .panel,
[data-theme="lucid"] .card,
[data-theme="lucid"] .model-card {
background: #ffffff;
}
[data-theme="lucid"] .card:hover,
[data-theme="lucid"] .panel:hover,
[data-theme="lucid"] .system-card:hover,
[data-theme="lucid"] .tool-card:hover,
[data-theme="lucid"] .model-card:hover {
border-color: var(--system-accent, var(--accent));
box-shadow: var(--hover-shadow);
transform: none;
}
/* A solid fill with white knocked out, the way a Lucid toolbar reads. No
* gradient: gradients are the first thing that looks wrong in print. */
[data-theme="lucid"] button[aria-pressed="true"],
[data-theme="lucid"] .active,
[data-theme="lucid"] .selected {
background: var(--accent);
border-color: var(--accent);
color: #ffffff;
}
[data-theme="lucid"] .label,
[data-theme="lucid"] .panel-title {
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
/* Pale-fill chips, the shape Lucid uses for tags on a shape. */
[data-theme="lucid"] .badge {
border-radius: var(--radius-sm);
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 10px;
background: #eaf0ff;
border: 1px solid #c3d4ff;
color: var(--accent-text);
}
[data-theme="lucid"] code,
[data-theme="lucid"] pre {
background: #f5f7fa;
border-color: var(--border);
}
/* Diagrams are the point of this theme, and they are rendered as <img>, so the
* page cannot colour them — it can only stop fighting them. A white-canvas SVG
* needs a frame to read as a figure rather than as a hole in the page. */
[data-theme="lucid"] img[src$=".svg"] {
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius);
}
/* Printing is a first-class output here: this theme exists to end up in a
* document. Drop the chrome that has no meaning on paper. */
@media print {
[data-theme="lucid"] #spr-theme-toggle,
[data-theme="lucid"] #spr-sidebar {
display: none !important;
}
[data-theme="lucid"] .panel,
[data-theme="lucid"] .card {
box-shadow: none;
break-inside: avoid;
}
}

View File

@@ -0,0 +1,110 @@
/* MCRN — the terminal read of the Expanse aesthetic, as built at mariano.mcrn.ar.
*
* The defining choices, all of them load-bearing:
* - zero corner radius, everywhere, including inputs and buttons
* - one hairline border colour and one accent; nothing in between
* - monospace for everything, not just code
* - headings are uppercase, letterspaced, unbolded, and ruled underneath
* - the only hover is a burnt-orange border and a glow behind it
*
* Values are lifted from ~/wdir/mcrn.ar/css/mcrn.css rather than approximated,
* so the two sites read as one system.
*/
[data-theme="mcrn"] {
--bg: #0a0a0a;
--bg-2: #141414;
--surface: #1a1a1a;
--surface-raised: #202020;
--border: #2a2a2a;
--border-strong: #3d3d3d;
--text: #e0e0e0;
--muted: #888;
--dim: #555;
--accent: #d35400;
--accent-dim: #c0392b;
--accent-text: #d35400;
--glow: rgba(211, 84, 0, 0.3);
--status-ok: #2ecc71;
--status-info: #5dade2;
--status-warn: #f39c12;
--status-error: #e74c3c;
--status-idle: #555;
/* Square. This is the single most recognisable thing about the theme, so
* it applies to the small radii too — a 4px input in a 0px page reads as a
* mistake rather than a detail. */
--radius-sm: 0;
--radius: 0;
--radius-lg: 0;
--radius-xl: 0;
--font-ui: "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", monospace;
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", monospace;
--font-heading: "JetBrains Mono", "Cascadia Mono", Consolas, "SF Mono", monospace;
--heading-transform: uppercase;
--heading-spacing: 0.1em;
--heading-weight: 400;
--label-spacing: 0.05em;
--speed-fast: 0.15s;
--speed: 0.2s;
/* Glow instead of lift: this theme never moves anything on hover. */
--hover-shadow: 0 0 20px var(--glow);
--hover-lift: none;
--focus-shadow: 0 0 10px var(--glow);
}
/* Section headings carry a rule, and the rule is part of the type. */
[data-theme="mcrn"] h2 {
color: var(--system-accent-text, var(--accent-text));
padding-bottom: var(--space-2);
border-bottom: var(--hairline) solid var(--border);
}
/* Prompt bullets. Lists in this theme are terminal output, not prose. */
[data-theme="mcrn"] ul:not([class*="reset"]) > li::marker {
content: "";
}
[data-theme="mcrn"] .prompt-list {
list-style: none;
padding-left: 0;
}
[data-theme="mcrn"] .prompt-list > li {
position: relative;
padding-left: 1.25rem;
}
[data-theme="mcrn"] .prompt-list > li::before {
content: ">";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 600;
}
/* The active state is a solid fill with the page colour punched out of it —
* the segmented-control move from the portfolio's language toggle. */
[data-theme="mcrn"] button[aria-pressed="true"],
[data-theme="mcrn"] .active,
[data-theme="mcrn"] .selected {
background: var(--accent);
color: var(--bg);
border-color: var(--accent);
}
[data-theme="mcrn"] .card:hover,
[data-theme="mcrn"] .panel:hover,
[data-theme="mcrn"] .system-card:hover,
[data-theme="mcrn"] .tool-card:hover,
[data-theme="mcrn"] .model-card:hover {
border-color: var(--system-accent, var(--accent));
box-shadow: 0 0 20px var(--glow);
transform: none;
}

View File

@@ -0,0 +1,113 @@
/* Soleprint — minimal, rounded, neon.
*
* The surface ramp and text colours come from common/ui/src/tokens.css, which
* is the most considered palette in the repo: near-black with a violet cast
* (#0d0d0f -> #26262f) rather than flat grey. The accent is the amber the brand
* page has always used (#d4a574) but which the token file never carried, so
* this is where the two finally agree.
*
* "Neon" here means light around an edge, not saturated fills: a 1px halo in
* the accent, translucent badge fills at 18/66 alpha, and a small lift on
* hover. Restrained enough to sit under dense tool UIs for an hour.
*/
[data-theme="soleprint"] {
--bg: #0d0d0f;
--bg-2: #16161a;
--surface: #16161a;
--surface-raised: #1e1e24;
--border: #2e2e38;
--border-strong: #3d3d4a;
--text: #e8e8f0;
--muted: #8888a0;
--dim: #555568;
--accent: #d4a574;
--accent-dim: #b8956a;
--accent-text: #e0b98d; /* lifted off the fill colour so small text holds up */
--glow: rgba(212, 165, 116, 0.32);
--status-ok: #3ecf8e;
--status-info: #4f9cf9;
--status-warn: #f5a623;
--status-error: #f06565;
--status-idle: #555568;
/* Rounded but restrained — nothing above 12px, so panels read as soft
* rather than as pills. */
--radius-sm: 4px;
--radius: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Mono", Consolas, monospace;
--font-heading: Inter, "Segoe UI", system-ui, Arial, sans-serif;
--heading-transform: none;
--heading-spacing: 0.02em;
--heading-weight: 600;
--label-spacing: 0.04em;
--speed-fast: 0.12s;
--speed: 0.2s;
--hover-shadow: 0 4px 14px rgba(0, 0, 0, 0.45), 0 0 0 1px var(--accent);
--hover-lift: translateY(-2px);
--focus-shadow: 0 0 0 2px var(--glow);
}
[data-theme="soleprint"] .card:hover,
[data-theme="soleprint"] .panel:hover,
[data-theme="soleprint"] .system-card:hover,
[data-theme="soleprint"] .tool-card:hover,
[data-theme="soleprint"] .model-card:hover {
border-color: var(--system-accent, var(--accent));
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45),
0 0 0 1px var(--system-accent, var(--accent));
transform: translateY(-2px);
}
[data-theme="soleprint"] button[aria-pressed="true"],
[data-theme="soleprint"] .active,
[data-theme="soleprint"] .selected {
border-color: var(--accent);
color: var(--bg);
background: linear-gradient(135deg, var(--accent), var(--accent-dim));
}
/* Uppercase micro-labels — the recurring motif across the station tools. */
[data-theme="soleprint"] .label,
[data-theme="soleprint"] .panel-title {
font-size: var(--font-size-sm);
font-weight: 600;
text-transform: uppercase;
letter-spacing: var(--label-spacing);
color: var(--muted);
}
/* Translucent badges, the <hex>18 fill / <hex>66 border pattern graphgen
* already uses for pk/fk/m2m chips. */
[data-theme="soleprint"] .badge {
border-radius: var(--radius-sm);
padding: 1px 6px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.03em;
background: #d4a57418;
border: 1px solid #d4a57466;
color: var(--accent-text);
}
/* The one animated state, carried over from common/ui's tokens.css. */
@keyframes spr-waiting-glow {
0% { box-shadow: 0 0 3px 1px var(--status-info); }
33% { box-shadow: 0 0 3px 1px var(--status-ok); }
66% { box-shadow: 0 0 3px 1px var(--status-warn); }
100% { box-shadow: 0 0 3px 1px var(--status-info); }
}
[data-theme="soleprint"] .waiting {
animation: spr-waiting-glow 2s linear infinite;
outline: 1px solid transparent;
}

View File

@@ -0,0 +1,221 @@
/* Soleprint theme contract — the variable names every page may rely on.
*
* This file declares the vocabulary and a neutral default. It sets no colours
* of its own worth looking at: themes/*.css supply those, scoped to
* [data-theme="..."] on <html>, and theme.js decides which one is active.
*
* Two naming families existed before this file and both are still in use, so
* both are answered here rather than renamed across a dozen templates:
*
* --bg / --surface / --border / --text / --muted / --amber
* the station tool templates and the docs site
* --surface-0..3 / --text-primary / --text-secondary / --panel-radius
* common/ui's Vue component library
*
* The theme files set the first family; the aliases at the bottom derive the
* second from it. A page that uses either name gets the same colour, and a
* theme author has one set of values to fill in.
*
* Served by run.py at /theme.css together with the theme files — see the
* handler beside /sidebar.css.
*/
/* No webfont import. This has to render on a locked-down Windows box with no
* egress and from a double-clicked file, and a blocked stylesheet there is a
* blank page or a stall, not a fallback. The stacks below resolve to something
* deliberate on every target: Segoe UI and Consolas ship with Windows, Inter and
* JetBrains Mono are picked up where they happen to be installed. */
:root {
/* ── surfaces ─────────────────────────────────────────────────────── */
--bg: #0a0a0a;
--bg-2: #141414;
--surface: #1a1a1a;
--surface-raised: #242424;
--border: #333;
--border-strong: #4a4a4a;
/* ── text ─────────────────────────────────────────────────────────── */
--text: #e5e5e5;
--muted: #a3a3a3;
--dim: #666;
/* ── accent ───────────────────────────────────────────────────────── */
--accent: #d4a574;
--accent-dim: #b8956a;
--accent-text: #d4a574; /* accent legible on --bg, where the fill is not */
--glow: rgba(212, 165, 116, 0.3);
/* Each subsystem keeps its own colour under every theme, so a page still
* announces which of artery / atlas / station you are looking at. Pages set
* --system-accent; this is the fallback for those that do not. */
--system-accent: var(--accent);
--system-accent-text: var(--accent-text);
/* ── status ───────────────────────────────────────────────────────── */
--status-ok: #3ecf8e;
--status-info: #4f9cf9;
--status-warn: #f5a623;
--status-error: #f06565;
--status-idle: #555568;
/* ── shape ────────────────────────────────────────────────────────── */
--radius-sm: 4px;
--radius: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--hairline: 1px;
/* ── type ─────────────────────────────────────────────────────────── */
--font-ui: "Segoe UI", Inter, system-ui, -apple-system, Arial, sans-serif;
--font-mono: "Cascadia Mono", "JetBrains Mono", Consolas, "SF Mono", monospace;
--font-heading: var(--font-ui);
--font-size-sm: 11px;
--font-size-base: 13px;
--font-size-lg: 15px;
--heading-transform: none;
--heading-spacing: 0;
--heading-weight: 600;
--label-spacing: 0.04em;
/* ── space ────────────────────────────────────────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
/* ── motion ───────────────────────────────────────────────────────── */
--speed-fast: 0.12s;
--speed: 0.2s;
--ease: ease;
/* Themes differ most in how a surface reacts, not in what colour it is.
* Both hover treatments are declared here so a page writes one rule and
* the theme decides whether it lifts, glows, or both. */
--hover-shadow: 0 4px 12px var(--glow);
--hover-lift: translateY(-2px);
--focus-shadow: 0 0 0 1px var(--accent);
/* ── aliases for names already in use ─────────────────────────────── */
/* The station tool templates and the docs site call the accent "amber". */
--amber: var(--accent);
--amber-dim: var(--accent-dim);
/* ── aliases for common/ui's token names ──────────────────────────── */
--surface-0: var(--bg);
--surface-1: var(--surface);
--surface-2: var(--surface-raised);
--surface-3: var(--border);
--text-primary: var(--text);
--text-secondary: var(--muted);
--text-dim: var(--dim);
--panel-radius: var(--radius);
--panel-border: var(--hairline) solid var(--border);
--panel-header-height: 36px;
--status-live: var(--status-ok);
--status-processing: var(--status-info);
--status-escalating: var(--status-warn);
}
/* ── element defaults ─────────────────────────────────────────────────── */
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
line-height: 1.6;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: var(--heading-weight);
text-transform: var(--heading-transform);
letter-spacing: var(--heading-spacing);
}
code, pre, kbd, samp {
font-family: var(--font-mono);
}
a {
color: var(--accent-text);
}
button {
background: var(--surface-raised);
color: var(--text);
border: var(--hairline) solid var(--border);
border-radius: var(--radius);
font-family: inherit;
cursor: pointer;
transition: border-color var(--speed-fast) var(--ease),
color var(--speed-fast) var(--ease),
background var(--speed-fast) var(--ease);
}
button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent-text);
}
button:disabled {
opacity: 0.4;
cursor: default;
}
input, select, textarea {
background: var(--bg);
color: var(--text);
border: var(--hairline) solid var(--border);
border-radius: var(--radius-sm);
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--focus-shadow);
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
::-webkit-scrollbar-track { background: transparent; }
/* The theme toggle theme.js injects. Fixed rather than placed, so it needs no
* cooperation from the page it lands on. */
#spr-theme-toggle {
position: fixed;
right: var(--space-3);
bottom: var(--space-3);
z-index: 99998;
display: flex;
gap: 0;
border: var(--hairline) solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--surface);
font-family: var(--font-mono);
font-size: 10px;
}
#spr-theme-toggle button {
border: none;
border-radius: 0;
background: var(--surface);
color: var(--dim);
padding: var(--space-1) var(--space-2);
text-transform: uppercase;
letter-spacing: var(--label-spacing);
}
#spr-theme-toggle button + button {
border-left: var(--hairline) solid var(--border);
}
#spr-theme-toggle button[aria-pressed="true"] {
background: var(--accent);
color: var(--bg);
}

View File

@@ -4,7 +4,22 @@
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./style.css": "./dist/style.css",
"./theme.css": "./src/theme.css",
"./tokens.css": "./src/tokens.css",
"./base.css": "./src/base.css",
"./dist/*": "./dist/*",
"./src/*": "./src/*"
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "vite build",
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "vue-tsc --noEmit"

View File

@@ -0,0 +1,8 @@
# pnpm 10+ moved settings here out of package.json.
#
# esbuild (vite's bundler) and vue-demi (pinia) need their postinstall to link
# platform binaries. Without this pnpm refuses to run them and exits non-zero,
# which makes every `pnpm typecheck` / `test` / `build` fail before it starts.
allowBuilds:
esbuild: true
vue-demi: true

View File

@@ -0,0 +1,69 @@
/* Framework base layer — element defaults written against tokens.css.
*
* This was duplicated byte-for-byte in every app's own styles.css (doocus-app,
* meetus-app). It is theme, not app, so it ships with the framework: an app that
* imports the framework gets a consistent shell without restating it.
*
* Retheme by replacing tokens.css — every value here resolves through it. */
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
height: 100%;
width: 100%;
}
body {
background: var(--surface-0);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
}
button {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-2);
border: var(--panel-border);
border-radius: var(--panel-radius);
padding: var(--space-1) var(--space-3);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--surface-3);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
input,
select,
textarea {
font-family: var(--font-ui);
font-size: var(--font-size-base);
color: var(--text-primary);
background: var(--surface-0);
border: var(--panel-border);
border-radius: 4px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--surface-3);
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
/**
* Agnostic seekable media player.
*
* Two-way bindable playback position and play state:
* <VideoPlayer :src="url" v-model:currentTime="t" v-model:playing="p" />
*
* External `currentTime` changes seek the element (guarded against the
* feedback loop with the element's own `timeupdate` events).
*/
const props = withDefaults(defineProps<{
/** Media source URL */
src: string
/** Playback position in seconds (v-model:currentTime) */
currentTime?: number
/** Whether the media is playing (v-model:playing) */
playing?: boolean
}>(), {
currentTime: 0,
playing: false,
})
const emit = defineEmits<{
'update:currentTime': [value: number]
'update:playing': [value: boolean]
/** Media duration once metadata has loaded */
duration: [value: number]
/** Playback reached the end */
ended: []
}>()
const video = ref<HTMLVideoElement | null>(null)
/** True while we are applying an external seek, so the resulting
* `timeupdate` does not echo back out as an update. */
let seekingFromProp = false
/** Tolerance (s) below which we treat positions as already in sync. */
const EPSILON = 0.25
watch(() => props.currentTime, (t) => {
const el = video.value
if (!el) return
if (Math.abs(el.currentTime - t) < EPSILON) return
seekingFromProp = true
el.currentTime = t
})
watch(() => props.playing, (shouldPlay) => {
const el = video.value
if (!el) return
if (shouldPlay && el.paused) {
void el.play().catch(() => { /* autoplay may be blocked; ignore */ })
} else if (!shouldPlay && !el.paused) {
el.pause()
}
})
function onTimeUpdate() {
const el = video.value
if (!el) return
if (seekingFromProp) {
seekingFromProp = false
return
}
emit('update:currentTime', el.currentTime)
}
function onSeeked() {
// Clear the guard once the browser settles the requested seek.
seekingFromProp = false
const el = video.value
if (el) emit('update:currentTime', el.currentTime)
}
function onLoadedMetadata() {
const el = video.value
if (!el) return
emit('duration', el.duration)
// Honour an initial position requested before metadata was ready.
if (props.currentTime && Math.abs(el.currentTime - props.currentTime) >= EPSILON) {
seekingFromProp = true
el.currentTime = props.currentTime
}
}
function onPlay() { emit('update:playing', true) }
function onPause() { emit('update:playing', false) }
function onEnded() {
emit('update:playing', false)
emit('ended')
}
onBeforeUnmount(() => {
// Release the media resource promptly.
const el = video.value
if (el) {
el.pause()
el.removeAttribute('src')
el.load()
}
})
</script>
<template>
<div class="video-player">
<video
ref="video"
class="video-el"
:src="src"
controls
preload="metadata"
@timeupdate="onTimeUpdate"
@seeked="onSeeked"
@loadedmetadata="onLoadedMetadata"
@play="onPlay"
@pause="onPause"
@ended="onEnded"
/>
</div>
</template>
<style scoped>
.video-player {
display: flex;
width: 100%;
height: 100%;
background: var(--surface-0);
align-items: center;
justify-content: center;
overflow: hidden;
}
.video-el {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
</style>

View File

@@ -1,4 +1,10 @@
// Framework public API
// Theme (tokens + base layer). Imported here so the visual identity is part of
// the bundle rather than something each consumer must remember to wire up —
// every component below styles itself with the variables it defines.
import './theme.css'
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource'
@@ -14,6 +20,7 @@ export { default as ResizeHandle } from './components/ResizeHandle.vue'
export { default as SplitPane } from './components/SplitPane.vue'
export { default as ParameterEditor } from './components/ParameterEditor.vue'
export type { ConfigField } from './components/ParameterEditor.vue'
export { default as VideoPlayer } from './components/VideoPlayer.vue'
// Renderers
export { default as LogRenderer } from './renderers/LogRenderer.vue'

View File

@@ -0,0 +1,11 @@
/* The whole visual identity in one import: design tokens + element defaults.
*
* index.ts imports this, so the theme travels with the bundle and cannot be
* forgotten — a dist that renders unthemed is a broken dist, not an unbranded
* one, because every component styles itself with var(--surface-0) and friends.
*
* Consumers of the built package import `soleprint-ui/style.css`.
* To retheme, override the variables from tokens.css after this import. */
@import './tokens.css';
@import './base.css';

View File

@@ -0,0 +1,37 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
/**
* Library build — emits dist/soleprint-ui.js plus a single dist/style.css
* containing the theme (tokens + base) and every component's scoped styles.
*
* The CSS is the point as much as the JS: components style themselves with
* var(--surface-0) and friends, so a bundle shipped without it renders broken.
*
* Peer packages are external so a consuming app resolves ONE copy of vue —
* two Vue instances break reactivity and provide/inject in ways that are
* miserable to debug.
*/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
formats: ['es'],
fileName: () => 'soleprint-ui.js',
cssFileName: 'style',
},
rollupOptions: {
external: ['vue', 'pinia', '@vue-flow/core', 'uplot'],
output: {
globals: { vue: 'Vue' },
},
},
},
})

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,7 +14,7 @@
box-sizing: border-box;
}
html {
background: #0a0a0a;
background: var(--bg);
}
body {
font-family:
@@ -25,8 +25,8 @@
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
background: #0a0a0a;
color: var(--text);
background: var(--bg);
}
/* Sidebar styles */
.sidebar {
@@ -35,8 +35,8 @@
left: 0;
width: 60px;
height: 100vh;
background: #1a1a1a;
border-right: 1px solid #333;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
@@ -52,16 +52,16 @@
border-radius: 8px;
margin-bottom: 0.5rem;
text-decoration: none;
color: #a3a3a3;
color: var(--muted);
transition: all 0.2s;
}
a.sidebar-item:hover {
background: #333;
background: var(--border);
color: white;
}
.sidebar-item.active {
background: #d4a574;
color: #0a0a0a;
background: var(--accent);
color: var(--bg);
}
.sidebar-item svg {
width: 24px;
@@ -70,7 +70,7 @@
.sidebar-divider {
width: 32px;
height: 1px;
background: #333;
background: var(--border);
margin: 0.5rem 0;
}
.sidebar-icon {
@@ -89,7 +89,7 @@
.sidebar-item .tooltip {
position: absolute;
left: 70px;
background: #333;
background: var(--border);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 4px;
@@ -121,18 +121,18 @@
color: white;
}
.tagline {
color: #a3a3a3;
color: var(--muted);
margin-bottom: 2rem;
border-bottom: 1px solid #333;
border-bottom: 1px solid var(--border);
padding-bottom: 2rem;
}
.mission {
background: #1a1a1a;
border-left: 3px solid #d4a574;
background: var(--surface);
border-left: 3px solid var(--accent);
padding: 1rem 1.5rem;
margin: 2rem 0;
border-radius: 0 8px 8px 0;
color: #d4a574;
color: var(--accent);
}
.systems {
display: grid;
@@ -171,11 +171,11 @@
.system-info p {
margin: 0;
font-size: 0.9rem;
color: #a3a3a3;
color: var(--muted);
}
.artery {
background: #1a1a1a;
background: var(--surface);
border: 1px solid #b91c1c;
}
.artery h2 {
@@ -186,7 +186,7 @@
}
.atlas {
background: #1a1a1a;
background: var(--surface);
border: 1px solid #15803d;
}
.atlas h2 {
@@ -197,7 +197,7 @@
}
.station {
background: #1a1a1a;
background: var(--surface);
border: 1px solid #1d4ed8;
}
.station h2 {
@@ -210,9 +210,9 @@
footer {
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid #333;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: #666;
color: var(--dim);
}
.showcase-container {
@@ -224,8 +224,8 @@
}
.showcase-link {
display: inline-block;
background: linear-gradient(135deg, #d4a574, #b8956a);
color: #0a0a0a;
background: linear-gradient(135deg, var(--accent), var(--accent-dim));
color: var(--bg);
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
@@ -237,18 +237,33 @@
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
}
.showcase-hint {
color: #666;
color: var(--dim);
font-size: 0.8rem;
text-decoration: none;
}
.showcase-hint:hover {
color: #d4a574;
color: var(--accent);
}
</style>
{% if managed %}
<link rel="stylesheet" href="/sidebar.css">
<script src="/sidebar.js"></script>
{% endif %}
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--accent: #d4a574;
--accent-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
</head>
<body{% if managed %} class="has-sidebar"{% endif %}>
@@ -428,5 +443,6 @@
</div>
<footer>soleprint</footer>
<script src="/theme.js" defer></script>
</body>
</html>

View File

@@ -5,6 +5,12 @@ pydantic>=2.5.0
pydantic-settings>=2.0.0
httpx>=0.25.0
jinja2>=3.1.0
# YAML: OpenAPI specs for shuntgen, and compose fragments for cabinets.
# modelgen keeps this optional (it imports it lazily) so the pip package stays
# dependency-free; the server always has it.
pyyaml>=6.0
# Multipart form parsing — file uploads in shuntgen's UI.
python-multipart>=0.0.9
# Database (databrowse)
sqlalchemy>=2.0.0

View File

@@ -249,19 +249,41 @@ def load_config() -> dict:
return {}
class SafeDict(dict):
"""A dict whose missing keys read as empty rather than exploding a template.
The landing pages ask for two levels at once — `components.composed.title`.
Jinja tolerates one missing level (it returns Undefined, and `or 'Desk'`
catches it) but not two: attribute access on Undefined raises, and the
whole page 500s.
That is not hypothetical. cfg/config.json is written into a room by
build.py, so it does not exist in the source tree at all, and running
`python run.py` from soleprint/ — the documented way to develop — took
/station/ down every time.
"""
def __getattr__(self, name: str):
# The dict's own contents win; only genuinely absent keys become empty.
if name in self:
value = self[name]
return SafeDict(value) if isinstance(value, dict) else value
return SafeDict()
def get_system_config(system_key: str) -> dict:
"""Get system configuration by key (data_flow, documentation, execution)."""
config = load_config()
for system in config.get("systems", []):
if system.get("key") == system_key:
return system
return {}
return SafeDict(system)
return SafeDict()
def get_components(system_key: str) -> dict:
"""Get component definitions for a system."""
config = load_config()
return config.get("components", {}).get(system_key, {})
return SafeDict(config.get("components", {}).get(system_key, {}))
def load_data(filename: str) -> list[dict]:
@@ -461,6 +483,41 @@ def atlas_route(path: str):
# === Station ===
def load_station_cabinets() -> list[dict]:
"""The dependency containers this room switched on.
Same two-step as artery's shunts: what the room declared, else what is on
disk. The room's list is the honest answer — the catalog holds every
cabinet that could be used, not the ones that were.
"""
declared = load_data("cabinets.json")
catalog = SPR_ROOT / "station" / "cabinets"
if declared:
cabinets = declared
else:
cabinets = [
{"name": path.name}
for path in sorted(catalog.iterdir())
if path.is_dir() and not path.name.startswith(("_", "."))
] if catalog.exists() else []
for cabinet in cabinets:
name = cabinet.get("name", "")
definition_path = catalog / name / "cabinet.json"
if definition_path.exists():
try:
definition = json.loads(definition_path.read_text())
for key in ("title", "description", "image", "rig_addon"):
cabinet.setdefault(key, definition.get(key))
except (OSError, ValueError):
pass
cabinet.setdefault("slug", name)
cabinet.setdefault("title", name.replace("-", " ").title())
cabinet.setdefault("status", "declared" if declared else "available")
return cabinets
@app.get("/station", response_class=HTMLResponse)
@app.get("/station/", response_class=HTMLResponse)
def station_index(request: Request):
@@ -486,6 +543,7 @@ def station_index(request: Request):
d["slug"] = d["name"]
d["title"] = d["name"].replace("-", " ").title()
d["status"] = "ready"
cabinets = load_station_cabinets()
from jinja2 import Template
template = Template(html_path.read_text())
@@ -497,6 +555,7 @@ def station_index(request: Request):
tools=tools,
monitors=monitors,
desks=desks,
cabinets=cabinets,
soleprint_url="/",
)
)
@@ -512,24 +571,18 @@ def station_index(request: Request):
)
# Mount station tool routers
# Mount station tool routers.
#
# Broad except on purpose: a tool that cannot load should cost you that tool,
# not the server. Route registration raises more than ImportError — FastAPI
# turns a missing optional dependency into a RuntimeError at decoration time —
# and catching only ImportError meant one such tool took the whole app down.
for _tool in ("tester", "graphgen", "datagen", "shuntgen"):
try:
from station.tools.tester.api import router as tester_router
app.include_router(tester_router, prefix="/station")
except ImportError as e:
print(f"Warning: Could not load tester router: {e}")
try:
from station.tools.graphgen.api import router as graphgen_router
app.include_router(graphgen_router, prefix="/station")
except ImportError as e:
print(f"Warning: Could not load graphgen router: {e}")
try:
from station.tools.datagen.api import router as datagen_router
app.include_router(datagen_router, prefix="/station")
except ImportError as e:
print(f"Warning: Could not load datagen router: {e}")
_module = importlib.import_module(f"station.tools.{_tool}.api")
app.include_router(_module.router, prefix="/station")
except Exception as e:
print(f"Warning: Could not load {_tool} router: {e}")
@app.get("/station/{path:path}")
@@ -541,6 +594,77 @@ def station_route(path: str):
# === Sidebar Wrapper (served at /spr/* when proxied) ===
# === Theme ===
#
# One stylesheet for every page, served the same way the sidebar is. tokens.css
# declares the variables and both theme files ship in the same response, so a
# page can switch themes without a second request and without FOUC.
def available_themes() -> list[str]:
"""Theme names, from the files themselves — adding one is adding a file."""
theme_dir = SPR_ROOT / "common" / "theme" / "themes"
if not theme_dir.exists():
return ["soleprint"]
names = sorted(p.stem for p in theme_dir.glob("*.css"))
# Default first, so a consumer taking names[0] gets the sensible one.
return sorted(names, key=lambda n: (n != "soleprint", n))
def get_default_theme() -> str:
"""The theme a page is served in, before the browser has an opinion."""
framework = load_config().get("framework", {})
theme = framework.get("theme", "soleprint")
return theme if theme in available_themes() else "soleprint"
@app.get("/theme.css")
def theme_css():
"""Serve the theme contract plus every theme, concatenated."""
from fastapi.responses import Response
theme_dir = SPR_ROOT / "common" / "theme"
parts = []
tokens = theme_dir / "tokens.css"
if tokens.exists():
parts.append(tokens.read_text())
# Sorted so the response is byte-stable and cacheable; the theme files are
# scoped to [data-theme] selectors, so their order carries no meaning.
for sheet in sorted((theme_dir / "themes").glob("*.css")):
parts.append(f"\n/* ── {sheet.stem} ── */\n")
parts.append(sheet.read_text())
if not parts:
return Response(
content="/* theme not found — is common/theme/ present? */",
media_type="text/css",
)
return Response(content="".join(parts), media_type="text/css")
@app.get("/theme.js")
def theme_js():
"""Serve the theme switcher."""
from fastapi.responses import Response
js_path = SPR_ROOT / "common" / "theme" / "theme.js"
if js_path.exists():
return Response(
content=js_path.read_text(), media_type="application/javascript"
)
return Response(
content="/* theme.js not found */", media_type="application/javascript"
)
@app.get("/api/theme")
def theme_config():
"""The server-side default, for pages that render their own <html> tag."""
return {"theme": get_default_theme(), "themes": available_themes()}
@app.get("/sidebar.css")
def sidebar_css():
"""Serve sidebar CSS for injection."""

View File

@@ -0,0 +1,91 @@
# Cabinets
A cabinet is a **dependency container** a room can switch on: postgres, redis,
airflow. The vocabulary already had the word — `execution.container` in every
room's `config.json` is *"Cabinet — tool container"* — and until now nothing
stood behind it.
The problem it solves is that a generated artifact knows what it needs and had
no way to say so. A shunt built from a client's spreadsheets can hold its rows
in memory, but the moment you want them to survive a restart you need postgres,
and wiring postgres in meant hand-editing a room's `docker-compose.yml` and then
hand-editing the cluster too. A cabinet is that declaration, made once and read
by both paths.
```
soleprint/station/cabinets/<name>/
cabinet.json what it is, what it needs, what it exports
service.yml the compose service, verbatim
```
## Turning one on
Add `cfg/<room>/data/cabinets.json` — the same shape as its sibling `data/*.json`
files:
```json
[
{ "name": "postgres" },
{ "name": "redis" },
{ "name": "airflow", "env": { "AIRFLOW_ADMIN_PASSWORD": "change-me" } }
]
```
Then build. `python build.py --cfg <room>` merges each cabinet's `service.yml`
into the room's `docker-compose.yml` and appends its settings to `.env.example`:
```bash
python build.py --cfg sample
cd gen/sample && docker compose up -d
```
**A service the room already declares wins.** `cfg/amar/docker-compose.yml`
ships its own `db`; switching on the postgres cabinet will not overwrite it.
Build says so when it skips one.
## On a cluster
`cabinet.json` names a `rig_addon`. Where the room runs on kind rather than
compose, the same dependency installs as a rig addon of that name:
```bash
cd rig
PROFILE=data make cluster up
PROFILE=data make addons install
```
The two paths are deliberately separate — compose for a laptop, helm for a
cluster — and `rig_addon` is the thread between them, so a room declares the
dependency once either way.
## Writing one
`cabinet.json`:
| Key | Purpose |
| --- | --- |
| `name` | must match the directory |
| `title`, `description` | shown on the station index |
| `image` | for the record; `service.yml` is what runs |
| `service` | the key to merge under in `services:` (defaults to `name`) |
| `env` | settings and defaults, written to `.env.example` |
| `volumes` | named volumes to declare at the top level |
| `depends_on` | other cabinets that must come with it |
| `rig_addon` | the matching `rig/ctrl/addons/<name>.sh`, if there is one |
| `ports` | host ports it wants, for the collision note in the docs |
`service.yml` is a plain compose fragment — one top-level key, the service name:
```yaml
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-soleprint}
...
```
Kept as YAML rather than generated from JSON so it reads like the compose file it
becomes, and so anything compose supports is available without this tool
learning about it first.
Adding a cabinet is adding a directory. Nothing dispatches on the name.

View File

@@ -0,0 +1,22 @@
{
"name": "airflow",
"title": "Apache Airflow",
"description": "Scheduled pipelines. DAGs live in the room, under dags/.",
"image": "apache/airflow:2.10.4",
"service": "airflow",
"rig_addon": "airflow",
"ports": [8080],
"volumes": ["airflow_logs"],
"depends_on": ["postgres", "redis"],
"env": {
"AIRFLOW_PORT": "8080",
"AIRFLOW_ADMIN_USER": "admin",
"AIRFLOW_ADMIN_PASSWORD": "change-me",
"AIRFLOW_DAGS_DIR": "./dags"
},
"notes": [
"Brings postgres and redis with it — Airflow needs a metadata database and a broker, and will not start without both.",
"LocalExecutor by default: one container, no separate worker. Switch to CeleryExecutor in service.yml when the room outgrows it.",
"8080 collides with almost everything. Set AIRFLOW_PORT in the room's .env."
]
}

View File

@@ -0,0 +1,37 @@
# Airflow — scheduler and webserver in one container, on LocalExecutor.
#
# One container rather than the five the official compose file ships, because a
# room switching this on wants pipelines, not a distributed deployment. The
# metadata database is the postgres cabinet, so the two arrive together; moving
# to CeleryExecutor is changing the executor here and adding a worker service.
airflow:
image: apache/airflow:2.10.4
container_name: ${DEPLOYMENT_NAME:-soleprint}_airflow
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: >-
postgresql+psycopg2://${POSTGRES_USER:-soleprint}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-soleprint}
AIRFLOW__CELERY__BROKER_URL: redis://redis:6379/0
AIRFLOW__CORE__LOAD_EXAMPLES: "false"
# Without a fixed key, every restart invalidates stored connections.
AIRFLOW__CORE__FERNET_KEY: ${AIRFLOW_FERNET_KEY:-}
AIRFLOW__WEBSERVER__EXPOSE_CONFIG: "true"
_AIRFLOW_DB_MIGRATE: "true"
_AIRFLOW_WWW_USER_CREATE: "true"
_AIRFLOW_WWW_USER_USERNAME: ${AIRFLOW_ADMIN_USER:-admin}
_AIRFLOW_WWW_USER_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-change-me}
volumes:
- ${AIRFLOW_DAGS_DIR:-./dags}:/opt/airflow/dags
- airflow_logs:/opt/airflow/logs
ports:
- "${AIRFLOW_PORT:-8080}:8080"
# `standalone` runs the migration, creates the admin user, and starts both
# the scheduler and the webserver — the whole first-boot sequence that the
# official compose file spreads across an init container and four services.
command: standalone
restart: unless-stopped

View File

@@ -0,0 +1,20 @@
{
"name": "postgres",
"title": "PostgreSQL",
"description": "Relational database. Backs rooms that need their data to outlive a restart.",
"image": "postgres:16-alpine",
"service": "postgres",
"rig_addon": "postgres",
"ports": [5432],
"volumes": ["pgdata"],
"env": {
"POSTGRES_DB": "soleprint",
"POSTGRES_USER": "soleprint",
"POSTGRES_PASSWORD": "change-me",
"POSTGRES_PORT": "5432"
},
"notes": [
"POSTGRES_PASSWORD is a placeholder. Set the real one in the room's .env, which is gitignored.",
"The connection string other services want is postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}"
]
}

View File

@@ -0,0 +1,24 @@
# PostgreSQL — merged into a room's docker-compose.yml when the room asks for it.
#
# The healthcheck is not decoration: anything with `depends_on: condition:
# service_healthy` waits on it, and without one a backend races the database on
# every cold start and fails its first migration.
postgres:
image: postgres:16-alpine
container_name: ${DEPLOYMENT_NAME:-soleprint}_postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-soleprint}
POSTGRES_USER: ${POSTGRES_USER:-soleprint}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
# Bound on the host so psql and databrowse can reach it from outside the
# compose network. Override POSTGRES_PORT when 5432 is already taken.
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-soleprint} -d ${POSTGRES_DB:-soleprint}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped

View File

@@ -0,0 +1,17 @@
{
"name": "redis",
"title": "Redis",
"description": "In-memory store. Cache, and the broker Celery and Airflow run their queues on.",
"image": "redis:7-alpine",
"service": "redis",
"rig_addon": "redis",
"ports": [6379],
"volumes": ["redisdata"],
"env": {
"REDIS_PORT": "6379"
},
"notes": [
"The URL other services want is redis://redis:6379/0",
"Airflow depends on this one; switching airflow on brings it along."
]
}

View File

@@ -0,0 +1,14 @@
# Redis — cache, and the broker for anything queue-shaped in the room.
redis:
image: redis:7-alpine
container_name: ${DEPLOYMENT_NAME:-soleprint}_redis
volumes:
- redisdata:/data
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -14,7 +14,7 @@
box-sizing: border-box;
}
html {
background: #0a0a0a;
background: var(--bg);
}
body {
font-family:
@@ -25,8 +25,8 @@
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
color: #e5e5e5;
background: #1d4ed8;
color: var(--text);
background: var(--system-accent);
}
header {
display: flex;
@@ -51,7 +51,7 @@
padding-bottom: 2rem;
}
section {
background: #0a0a0a;
background: var(--bg);
padding: 1.5rem;
margin: 1.5rem 0;
border-radius: 12px;
@@ -59,23 +59,23 @@
section h2 {
margin: 0 0 1rem 0;
font-size: 1.2rem;
color: #93c5fd;
color: var(--system-accent-text);
}
.composition {
background: #1a1a1a;
border: 2px solid #1d4ed8;
background: var(--surface);
border: 2px solid var(--system-accent);
padding: 1rem;
border-radius: 12px;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #93c5fd;
color: var(--system-accent-text);
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
color: var(--muted);
}
.components {
display: grid;
@@ -83,20 +83,20 @@
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
background: var(--bg);
border: 1px solid var(--border-strong);
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #93c5fd;
color: var(--system-accent-text);
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
color: var(--muted);
}
.tables {
list-style: none;
@@ -105,7 +105,7 @@
}
.tables li {
padding: 0.75rem 0;
border-bottom: 1px solid #3f3f3f;
border-bottom: 1px solid var(--border-strong);
display: flex;
justify-content: space-between;
align-items: center;
@@ -116,32 +116,32 @@
.tables .name {
font-weight: 500;
text-decoration: none;
color: #e5e5e5;
color: var(--text);
}
.tables a.name:hover {
color: #93c5fd;
color: var(--system-accent-text);
}
.status {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
background: #2a2a2a;
color: #a3a3a3;
background: var(--border);
color: var(--muted);
}
.health {
display: inline-block;
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #1a1a1a;
border: 1px solid #3f3f3f;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 4px;
font-family: monospace;
color: #93c5fd;
color: var(--system-accent-text);
text-decoration: none;
}
.health:hover {
background: #2a2a2a;
background: var(--border);
}
footer {
margin-top: 3rem;
@@ -157,6 +157,25 @@
opacity: 0.5;
}
</style>
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0d0d0f;
--border: #2e2e38;
--border-strong: #3d3d4a;
--muted: #8888a0;
--surface: #16161a;
--system-accent: #d4a574;
--system-accent-text: #e0b98d;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
/* Station keeps its own colour under every theme. */
:root { --system-accent: #1d4ed8; --system-accent-text: #93c5fd; }
</style>
</head>
<body>
<header style="position: relative">
@@ -217,6 +236,20 @@
</ul>
</section>
<section>
<h2>{{ (components.container.plural or 'cabinets')|title }}</h2>
<ul class="tables">
{% for cabinet in cabinets %}
<li>
<span class="name">{{ cabinet.title }}</span
><span class="status">{{ cabinet.status }}</span>
</li>
{% else %}
<li><span class="name">--</span></li>
{% endfor %}
</ul>
</section>
<section>
<h2>{{ (components.watcher.plural or 'monitors')|title }}</h2>
<ul class="tables">
@@ -263,5 +296,6 @@
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
</footer>
<script src="/theme.js" defer></script>
</body>
</html>

View File

@@ -15,26 +15,26 @@ This monitor provides at-a-glance views of the database grouped by test-relevant
## Architecture
Follows pawprint **book/larder pattern**:
- **larder/** contains all data files (schema, views, scenarios)
Follows the book/depot pattern:
- **depot/** contains all data files (schema, views, scenarios)
- **main.py** generates SQL queries from view definitions
- Two modes: **SQL** (direct queries) and **API** (Django backend, placeholder)
### Key Concepts
**Schema** (`larder/schema.json`)
**Schema** (`depot/schema.json`)
- AMAR data model with SQL table mappings
- Regular fields (from database columns)
- Computed fields (SQL expressions)
- Support for multiple graph generators
**Views** (`larder/views.json`)
**Views** (`depot/views.json`)
- Define what to display and how to group it
- Each view targets an entity (User, PetOwner, Veterinarian, etc.)
- Can group results (e.g., by role, by data state, by availability)
- SQL is generated automatically from view configuration
**Scenarios** (`larder/scenarios.json`)
**Scenarios** (`depot/scenarios.json`)
- Test scenarios emerge from actual usage
- Format defined, real scenarios added as needed
- Links scenarios to specific views with filters
@@ -49,21 +49,21 @@ Follows pawprint **book/larder pattern**:
## Running Locally
```bash
cd /home/mariano/wdir/ama/pawprint/ward/monitor/data_browse
cd soleprint/station/monitors/databrowse
python main.py
# Opens on http://localhost:12020
```
Or with uvicorn:
```bash
uvicorn ward.monitor.data_browse.main:app --port 12020 --reload
uvicorn station.monitors.databrowse.main:app --port 12020 --reload
```
## Environment Variables
```bash
# Database connection (defaults to local dev)
export NEST_NAME=local
export ROOM_NAME=local
export DB_HOST=localhost
export DB_PORT=5433
export DB_NAME=amarback
@@ -85,7 +85,7 @@ GET /api/scenarios # Test scenarios (JSON)
## Adding New Views
Edit `larder/views.json`:
Edit `depot/views.json`:
```json
{
@@ -112,7 +112,7 @@ The SQL query is automatically generated from:
## Adding Computed Fields
Edit `larder/schema.json` in the entity definition:
Edit `depot/schema.json` in the entity definition:
```json
"computed": {
@@ -127,7 +127,7 @@ Computed fields can be used in views just like regular fields.
## Adding Test Scenarios
As you identify test patterns, add them to `larder/scenarios.json`:
As you identify test patterns, add them to `depot/scenarios.json`:
```json
{
@@ -153,8 +153,8 @@ As you identify test patterns, add them to `larder/scenarios.json`:
```
data_browse/
├── larder/
│ ├── .larder # Larder marker (book pattern)
├── depot/
│ ├── .depot # Depot marker (book pattern)
│ ├── schema.json # AMAR data model with SQL mappings
│ ├── views.json # View configurations
│ └── scenarios.json # Test scenarios

View File

@@ -245,7 +245,7 @@
{% if views|length == 0 %}
<div class="empty">
No views configured. Add views to larder/views.json
No views configured. Add views to depot/views.json
</div>
{% else %}
<div class="card-grid">
@@ -289,7 +289,7 @@
<div class="empty">
No scenarios defined yet. Scenarios emerge from usage and
conversations.
<br />Add them to larder/scenarios.json as you identify test
<br />Add them to depot/scenarios.json as you identify test
patterns.
</div>
{% else %}

View File

@@ -1,164 +1,87 @@
# Datagen - Test Data Generator
# Datagen Test Data Generator
Pluggable test data generators for various domain models and external APIs.
Room-specific test data generators, discovered and served by the hub.
## Purpose
- Generate realistic test data for Amar domain models
- Generate mock API responses for external services (MercadoPago, etc.)
- Can be plugged into any nest (test suites, mock veins, seeders)
- Domain-agnostic and reusable
The core ships the base class and the API only. **Generators themselves belong to a
room** (`cfg/<room>/soleprint/station/tools/datagen/`) and are merged into the built
instance — so no client's domain vocabulary lives here.
## Structure
```
datagen/
├── __init__.py
├── amar.py # Amar domain models (petowner, pet, cart, etc.)
├── mercadopago.py # MercadoPago API responses
── README.md # This file
├── base.py # BaseDataGenerator — the contract
├── api.py # FastAPI router, mounted at /tools/datagen
── templates/
│ └── index.html # browser UI
└── README.md # this file
```
## Usage
## Writing a generator
### In Tests
Subclass `BaseDataGenerator` and name each method after the model it generates. The
method name *is* the model name — there is no registry to update.
```python
from ward.tools.datagen.amar import AmarDataGenerator
from faker import Faker
def test_petowner_creation():
owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234")
assert owner_data["address"] == "Av. Corrientes 1234"
```
# Guarded so the file also runs on its own, outside a built instance —
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
try:
from station.tools.datagen.base import BaseDataGenerator
except ImportError:
class BaseDataGenerator:
pass
### In Mock Veins
fake = Faker()
```python
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
@router.post("/v1/preferences")
async def create_preference(request: dict):
# Generate mock response
return MercadoPagoDataGenerator.preference(
description=request["items"][0]["title"],
total=request["items"][0]["unit_price"],
)
```
### In Seeders
class MyRoomGenerator(BaseDataGenerator):
def user(self, **kwargs):
return {"id": fake.uuid4(), "name": fake.name(), **kwargs}
```python
from ward.tools.datagen.amar import AmarDataGenerator
# Create 10 test pet owners
for i in range(10):
owner = AmarDataGenerator.petowner(is_guest=False)
# Save to database...
def product(self, category=None, **kwargs):
return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs}
```
## Design Principles
1. **Pluggable**: Can be used anywhere, not tied to specific frameworks
2. **Realistic**: Generated data matches real-world patterns
3. **Flexible**: Override any field via `**overrides` parameter
4. **Domain-focused**: Each generator focuses on a specific domain
5. **Stateless**: Pure functions, no global state
Drop it in `cfg/<room>/soleprint/station/tools/datagen/<name>.py` and rebuild the room.
## Generators
**Discovery rules** (`api.py:_load_generators`): every `*.py` in the tool directory is
scanned except `base.py`, `api.py`, and anything starting with `_`. The first class whose
name ends in `Generator` (and isn't `BaseDataGenerator`) is instantiated.
### AmarDataGenerator (amar.py)
## What the base class gives you
Generates data for Amar platform:
| Method | Purpose |
|---|---|
| `generate(model, count=1, **kwargs)` | Call the matching method `count` times; raises `ValueError` listing available models if there's no match |
| `available_models()` | Method names, minus the reserved ones — i.e. the models you support |
| `schema()` | Optional override returning a graphgen-compatible schema; `None` by default |
- `petowner()` - Pet owners (guest and registered)
- `pet()` - Pets with species, age, etc.
- `cart()` - Shopping carts
- `service_request()` - Service requests
- `filter_services()` - Service filtering by species/neighborhood
- `filter_categories()` - Category filtering
- `calculate_cart_summary()` - Cart totals with discounts
## HTTP API
### MercadoPagoDataGenerator (mercadopago.py)
Mounted at `/tools/datagen`:
Generates MercadoPago API responses:
| Route | Purpose |
|---|---|
| `GET /api/generators` | loaded generator files and their models |
| `GET /api/models` | models for one generator (`?generator=<name>`) |
| `POST /api/generate` | `{model, count, generator?, kwargs}` → generated items |
| `GET /api/schema` | graphgen-compatible schema, when the generator exposes one |
- `preference()` - Checkout Pro preference
- `payment()` - Payment (Checkout API/Bricks)
- `merchant_order()` - Merchant order
- `oauth_token()` - OAuth token exchange
- `webhook_notification()` - Webhook payloads
With one generator loaded, `generator` can be omitted everywhere — the only one is used.
## Examples
### Generate a complete turnero flow
```python
from ward.tools.datagen.amar import AmarDataGenerator
# Step 1: Guest pet owner
owner = AmarDataGenerator.petowner(
address="Av. Santa Fe 1234, Palermo",
is_guest=True
)
# Step 2: Pet
pet = AmarDataGenerator.pet(
owner_id=owner["id"],
name="Luna",
species="DOG",
age_value=3,
age_unit="years"
)
# Step 3: Cart
cart = AmarDataGenerator.cart(owner_id=owner["id"])
# Step 4: Add services to cart
services = AmarDataGenerator.filter_services(
species="DOG",
neighborhood_id=owner["neighborhood"]["id"]
)
cart_with_items = AmarDataGenerator.calculate_cart_summary(
cart,
items=[
{"service_id": services[0]["id"], "price": services[0]["price"], "quantity": 1, "pet_id": pet["id"]},
]
)
# Step 5: Service request
request = AmarDataGenerator.service_request(cart_id=cart["id"])
```
### Generate a payment flow
```python
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
# Create preference
pref = MercadoPagoDataGenerator.preference(
description="Visita a domicilio",
total=95000,
external_reference="SR-12345"
)
# Simulate payment
payment = MercadoPagoDataGenerator.payment(
transaction_amount=95000,
description="Visita a domicilio",
status="approved",
application_fee=45000 # Platform fee (split payment)
)
# Webhook notification
webhook = MercadoPagoDataGenerator.webhook_notification(
topic="payment",
resource_id=str(payment["id"])
)
```bash
curl -X POST localhost:12000/tools/datagen/api/generate \
-H 'content-type: application/json' \
-d '{"model": "user", "count": 3}'
```
## Future Generators
## Design principles
- `google.py` - Google API responses (Calendar, Sheets)
- `whatsapp.py` - WhatsApp API responses
- `slack.py` - Slack API responses
1. **Room-owned** — domain vocabulary lives in `cfg/<room>/`, never in core.
2. **Convention over registration** — a method name is a model name.
3. **Flexible** — any field is overridable through `**kwargs`.
4. **Stateless** — no global state between calls.
5. **Standalone** — usable directly as a Python class, with or without the hub.

View File

@@ -1,27 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>datagen — Test Data Generator</title>
<!-- Palette, fonts and the theme switcher. The :root block that used to sit
here was one of eight copies that had already drifted apart. -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0a0a0a;
--surface: #1a1a1a;
--border: #333;
--text: #e5e5e5;
--muted: #a3a3a3;
--dim: #666;
--amber: #d4a574;
--amber-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-family: var(--font-ui);
font-size: 13px;
height: 100vh;
display: flex;
@@ -493,5 +500,6 @@ document.addEventListener('keydown', e => {
init();
</script>
<script src="/theme.js" defer></script>
</body>
</html>

View File

@@ -130,10 +130,19 @@ def _convert_modelgen(loader: Any, source: str) -> dict:
fields = []
for field in model_def.fields:
type_str = _type_str(field.type_hint)
fk_target = None
# Prefer explicit metadata set by introspection extractors
# (DatabaseExtractor / SqlAlchemyExtractor); fall back to inference.
fk_target = getattr(field, "foreign_key", None)
if fk_target:
relationships.append({
"from_model": model_def.name,
"from_field": field.name,
"to_model": fk_target,
"type": "FK",
})
# FK: type name that matches another model
if type_str in all_names:
elif type_str in all_names:
fk_target = type_str
relationships.append({
"from_model": model_def.name,
@@ -145,10 +154,12 @@ def _convert_modelgen(loader: Any, source: str) -> dict:
elif type_str == "FK":
fk_target = None # target unknown from extractor
is_pk = getattr(field, "primary_key", False) or field.name == "id"
fields.append({
"name": field.name,
"type": type_str,
"pk": field.name == "id",
"pk": is_pk,
"fk": fk_target,
"m2m": type_str == "M2M",
"nullable": field.optional,

View File

@@ -1,27 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="soleprint">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>graphgen — Schema Explorer</title>
<!-- Palette, fonts and the theme switcher — see common/theme/. -->
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
<style>
:root {
--bg: #0a0a0a;
--surface: #1a1a1a;
--border: #333;
--text: #e5e5e5;
--muted: #a3a3a3;
--dim: #666;
--amber: #d4a574;
--amber-dim: #b8956a;
--bg: #0d0d0f;
--border: #2e2e38;
--dim: #555568;
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
--muted: #8888a0;
--surface: #16161a;
--text: #e8e8f0;
}
</style>
<!-- /theme:baked-defaults -->
<link rel="stylesheet" href="/theme.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
font-family: var(--font-ui);
font-size: 13px;
overflow: hidden;
height: 100vh;
@@ -725,5 +731,6 @@ function svgEl(tag) {
applyViewport();
init();
</script>
<script src="/theme.js" defer></script>
</body>
</html>

View File

@@ -6,7 +6,8 @@ Generates typed models from various sources to various output formats.
Input sources:
- Configuration files (soleprint config.json style)
- Python dataclasses in schema/ folder
- Existing codebases: Django, SQLAlchemy, Prisma (for extraction)
- Existing codebases: Django, SQLAlchemy (for extraction)
- Live databases: any SQLAlchemy dialect (PostgreSQL, MySQL, SQLite, ...)
Output formats:
- pydantic: Pydantic BaseModel classes
@@ -14,15 +15,17 @@ Output formats:
- typescript: TypeScript interfaces
- protobuf: Protocol Buffer definitions
- prisma: Prisma schema
- schema: graphgen-compatible schema.json (portable schema source)
Usage:
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
python -m soleprint.station.tools.modelgen from-db --url sqlite:///app.db --targets typescript,schema -o out/
python -m soleprint.station.tools.modelgen list-formats
"""
__version__ = "0.2.0"
__version__ = "0.3.0"
from .generator import GENERATORS, BaseGenerator
from .loader import ConfigLoader, load_config

View File

@@ -6,7 +6,10 @@ Generates typed models from various sources to various formats.
Input sources:
- from-config: Configuration files (soleprint config.json style)
- from-schema: Python dataclasses in schema/ folder
- extract: Existing codebases (Django, SQLAlchemy, Prisma)
- extract: Existing codebases (Django, SQLAlchemy)
- from-db: A live database, any SQLAlchemy dialect
- from-openapi: An OpenAPI 3.x / Swagger 2.0 document
- from-tabular: A directory of CSV/TSV/ODS spreadsheets
Output formats:
- pydantic: Pydantic BaseModel classes
@@ -14,12 +17,16 @@ Output formats:
- typescript: TypeScript interfaces
- protobuf: Protocol Buffer definitions
- prisma: Prisma schema
- schema: graphgen-compatible schema.json
- datagen: BaseDataGenerator subclass for station's datagen tool
Usage:
python -m soleprint.station.tools.modelgen --help
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
python -m soleprint.station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,schema
python -m soleprint.station.tools.modelgen from-tabular -s ./sheets -o out/ -t pydantic,datagen
python -m soleprint.station.tools.modelgen generate --config schema/modelgen.json
"""
@@ -178,6 +185,135 @@ def cmd_extract(args):
print("Done!")
def cmd_from_db(args):
"""Extract models from a live database (any SQLAlchemy dialect)."""
from .loader.extract.database import DatabaseExtractor
include = {t.strip() for t in args.include.split(",")} if args.include else None
exclude = {t.strip() for t in args.exclude.split(",")} if args.exclude else None
extractor = DatabaseExtractor(
url=args.url,
schema=args.schema,
include=include,
exclude=exclude,
)
print(f"Reflecting database: {args.url}")
try:
models, enums = extractor.extract()
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(f"Extracted {len(models)} models, {len(enums)} enums")
# Parse targets
targets = [t.strip() for t in args.targets.split(",")]
output_dir = Path(args.output)
for target in targets:
if target not in GENERATORS:
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
continue
generator = GENERATORS[target]()
ext = generator.file_extension()
# Determine output filename (use target name to avoid overwrites)
if len(targets) == 1 and args.output.endswith(ext):
output_file = output_dir
else:
output_file = output_dir / f"models_{target}{ext}"
print(f"Generating {target} to: {output_file}")
generator.generate((models, enums), output_file)
print("Done!")
def _emit(payload, targets_arg: str, output: str) -> None:
"""Run one extraction result through every requested target.
The three older commands each carry their own copy of this loop; the two
below share it, because they also have to pass datasets through and a
fourth copy would be a fourth place to keep in step.
"""
targets = [t.strip() for t in targets_arg.split(",") if t.strip()]
output_dir = Path(output)
for target in targets:
if target not in GENERATORS:
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
continue
generator = GENERATORS[target]()
ext = generator.file_extension()
# Determine output filename (use target name to avoid overwrites)
if len(targets) == 1 and output.endswith(ext):
output_file = output_dir
else:
output_file = output_dir / f"models_{target}{ext}"
print(f"Generating {target} to: {output_file}")
generator.generate(payload, output_file)
print("Done!")
def cmd_from_openapi(args):
"""Generate models from an OpenAPI 3.x / Swagger 2.0 document."""
from .loader.extract.openapi import OpenAPIExtractor
spec_path = Path(args.spec)
if not spec_path.exists():
print(f"Error: Spec not found: {spec_path}", file=sys.stderr)
sys.exit(1)
extractor = OpenAPIExtractor(spec_path)
print(f"Reading spec: {spec_path}")
try:
models, enums = extractor.extract()
except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
endpoints = extractor.endpoints()
print(
f"Extracted {len(models)} models, {len(enums)} enums, "
f"{len(endpoints)} endpoints"
)
_emit((models, enums), args.targets, args.output)
def cmd_from_tabular(args):
"""Generate models from a directory of CSV/TSV/ODS spreadsheets."""
from .loader.extract.tabular import TabularExtractor
source_path = Path(args.source)
if not source_path.exists():
print(f"Error: Source not found: {source_path}", file=sys.stderr)
sys.exit(1)
extractor = TabularExtractor(source_path)
print(f"Reading sheets: {source_path}")
try:
models, enums = extractor.extract()
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
datasets = extractor.datasets()
rows = sum(len(d.rows) for d in datasets)
print(f"Extracted {len(models)} models, {rows} rows")
# Datasets ride along as a third element: the datagen target seeds from
# them, and every other target ignores the extra slot.
_emit((models, enums, datasets), args.targets, args.output)
def cmd_generate(args):
"""Generate all targets from a JSON config file."""
import json
@@ -337,6 +473,108 @@ def main():
)
extract_parser.set_defaults(func=cmd_extract)
# from-db command (live database introspection, any dialect)
db_parser = subparsers.add_parser(
"from-db",
help="Extract models from a live database (any SQLAlchemy dialect)",
)
db_parser.add_argument(
"--url",
"-u",
type=str,
required=True,
help="SQLAlchemy connection URL (e.g. postgresql://…, mysql://…, sqlite:///path.db)",
)
db_parser.add_argument(
"--schema",
type=str,
default=None,
help="Database schema to reflect (dialect-dependent; default: connection default)",
)
db_parser.add_argument(
"--include",
type=str,
default=None,
help="Comma-separated table names to include (default: all)",
)
db_parser.add_argument(
"--exclude",
type=str,
default=None,
help="Comma-separated table names to exclude",
)
db_parser.add_argument(
"--output",
"-o",
type=str,
required=True,
help="Output path (file or directory)",
)
db_parser.add_argument(
"--targets",
"-t",
type=str,
default="typescript",
help=f"Comma-separated output targets ({formats_str})",
)
db_parser.set_defaults(func=cmd_from_db)
# from-openapi command (service contract -> models)
openapi_parser = subparsers.add_parser(
"from-openapi",
help="Generate models from an OpenAPI 3.x / Swagger 2.0 document",
)
openapi_parser.add_argument(
"--spec",
"-s",
type=str,
required=True,
help="Path to the spec (.json, .yaml or .yml; YAML needs PyYAML)",
)
openapi_parser.add_argument(
"--output",
"-o",
type=str,
required=True,
help="Output path (file or directory)",
)
openapi_parser.add_argument(
"--targets",
"-t",
type=str,
default="pydantic",
help=f"Comma-separated output targets ({formats_str})",
)
openapi_parser.set_defaults(func=cmd_from_openapi)
# from-tabular command (spreadsheets -> models + rows)
tabular_parser = subparsers.add_parser(
"from-tabular",
help="Generate models from a directory of CSV/TSV/ODS spreadsheets",
)
tabular_parser.add_argument(
"--source",
"-s",
type=str,
required=True,
help="Directory of sheets (or a single .csv/.tsv/.ods file)",
)
tabular_parser.add_argument(
"--output",
"-o",
type=str,
required=True,
help="Output path (file or directory)",
)
tabular_parser.add_argument(
"--targets",
"-t",
type=str,
default="pydantic",
help=f"Comma-separated output targets ({formats_str}). "
"The datagen target seeds from the imported rows.",
)
tabular_parser.set_defaults(func=cmd_from_tabular)
# generate command (config-driven multi-target)
gen_parser = subparsers.add_parser(

View File

@@ -8,12 +8,15 @@ Supported generators:
- ProtobufGenerator: Protocol Buffer definitions
- PrismaGenerator: Prisma schema
- StrawberryGenerator: Strawberry type/input/enum classes
- DatagenGenerator: BaseDataGenerator subclass for station's datagen tool
"""
from typing import Dict, Type
from .base import BaseGenerator
from .datagen import DatagenGenerator
from .django import DjangoGenerator
from .jsonschema import JsonSchemaGenerator
from .prisma import PrismaGenerator
from .protobuf import ProtobufGenerator
from .pydantic import PydanticGenerator
@@ -32,15 +35,20 @@ GENERATORS: Dict[str, Type[BaseGenerator]] = {
"proto": ProtobufGenerator, # Alias
"prisma": PrismaGenerator,
"strawberry": StrawberryGenerator,
"schema": JsonSchemaGenerator,
"jsonschema": JsonSchemaGenerator, # Alias
"datagen": DatagenGenerator,
}
__all__ = [
"BaseGenerator",
"DatagenGenerator",
"PydanticGenerator",
"DjangoGenerator",
"StrawberryGenerator",
"TypeScriptGenerator",
"ProtobufGenerator",
"PrismaGenerator",
"JsonSchemaGenerator",
"GENERATORS",
]

View File

@@ -0,0 +1,399 @@
"""
Datagen Generator
Emits a BaseDataGenerator subclass — the bridge between modelgen (which knows
the shapes) and datagen (which hands out instances of them).
The contract it targets is datagen/base.py: a method per model, named the way
`generate()` looks it up (`model.lower()`), plus a `schema()` override in the
graphgen-compatible format so the result also renders in graphgen with no extra
work.
Two modes, chosen by what the loader had:
- No rows → every field is synthesised from its type.
- Rows present → the generated class samples depot/data.json, so a shunt
built from a spreadsheet answers with the real values and
only invents where it must.
Synthesis uses random/uuid/datetime from the standard library rather than
faker, which is not a dependency of this repo despite what datagen's README
examples suggest.
"""
import json
from enum import Enum
from pathlib import Path
from pprint import pformat
from typing import Any, Dict, List, Optional
from ..helpers import unwrap_optional
from ..loader.schema import ModelDefinition
from .base import BaseGenerator
# Field-name heuristics. A column called `email` deserves something that looks
# like an email — a mock whose every string is "string_4" is hard to read and
# hard to demo.
_NAME_HINTS = (
("email", '"user{n}@example.com".format(n=random.randint(1, 999))'),
("phone", '"+1-555-{n:04d}".format(n=random.randint(0, 9999))'),
("url", '"https://example.com/{n}".format(n=random.randint(1, 999))'),
("slug", 'random.choice(_WORDS) + "-" + str(random.randint(1, 99))'),
("first_name", "random.choice(_FIRST_NAMES)"),
("last_name", "random.choice(_LAST_NAMES)"),
("name", 'random.choice(_FIRST_NAMES) + " " + random.choice(_LAST_NAMES)'),
("title", 'random.choice(_WORDS).title() + " " + random.choice(_WORDS)'),
("description", '" ".join(random.choices(_WORDS, k=8))'),
("address", '"{n} ".format(n=random.randint(1, 9999)) + random.choice(_WORDS).title() + " St"'),
("city", "random.choice(_CITIES)"),
("country", "random.choice(_COUNTRIES)"),
("currency", 'random.choice(["USD", "EUR", "ARS", "GBP"])'),
("status", 'random.choice(["active", "pending", "closed"])'),
("code", '"".join(random.choices("ABCDEFGHJKLMNPQRSTUVWXYZ0123456789", k=6))'),
("token", "uuid.uuid4().hex"),
("color", '"#{n:06x}".format(n=random.randint(0, 0xFFFFFF))'),
)
_HEADER_HELPERS = '''
_WORDS = [
"alpha", "bravo", "cobalt", "delta", "ember", "falcon", "granite", "harbor",
"indigo", "juniper", "kestrel", "lumen", "meridian", "nimbus", "onyx",
]
_FIRST_NAMES = ["Ada", "Bruno", "Camila", "Diego", "Elena", "Facundo", "Gabriela", "Hugo"]
_LAST_NAMES = ["Alvarez", "Bianchi", "Castro", "Duarte", "Esposito", "Ferrari", "Gomez"]
_CITIES = ["Buenos Aires", "Rosario", "Cordoba", "Montevideo", "Santiago", "Lisbon"]
_COUNTRIES = ["AR", "UY", "CL", "BR", "PT", "ES"]
'''
_FALLBACK_BASE = '''
# Standalone fallback: a generated generator has to keep working inside a
# shunt, which runs as its own process with no soleprint on the path. This
# mirrors station/tools/datagen/base.py.
class BaseDataGenerator: # type: ignore[no-redef]
"""Minimal stand-in for station.tools.datagen.base.BaseDataGenerator."""
_RESERVED = frozenset({"generate", "available_models", "schema"})
def generate(self, model: str, count: int = 1, **kwargs) -> list:
method = getattr(self, model.lower(), None)
if method is None or not callable(method):
raise ValueError(
f"No generator for '{model}'. Available: {self.available_models()}"
)
return [method(**kwargs) for _ in range(count)]
def available_models(self) -> list:
return sorted(
name for name in dir(self)
if not name.startswith("_")
and name not in self._RESERVED
and callable(getattr(self, name))
)
def schema(self):
return None
'''
class DatagenGenerator(BaseGenerator):
"""Generates a BaseDataGenerator subclass from model definitions."""
def __init__(
self,
name_map: Optional[Dict[str, str]] = None,
class_name: Optional[str] = None,
depot: str = "depot/data.json",
):
super().__init__(name_map)
self.class_name = class_name
# Relative to the generated file, so the shunt directory stays movable.
self.depot = depot
def file_extension(self) -> str:
return ".py"
def generate(self, models, output_path: Path) -> None:
model_defs, _enums, datasets = self._unpack(models)
output_path = Path(output_path)
if output_path.suffix != ".py":
output_path = output_path / "generated_datagen.py"
output_path.parent.mkdir(parents=True, exist_ok=True)
class_name = self.class_name or self._derive_class_name(output_path)
seeded = {d.model for d in datasets if d.rows}
model_names = {self.map_name(m.name) for m in model_defs}
lines = self._header(class_name, bool(seeded))
for model_def in model_defs:
lines.extend(self._model_method(model_def, model_names, seeded))
lines.append("")
lines.extend(self._schema_method(model_defs, model_names))
lines.append("")
lines.extend(self._helpers(bool(seeded)))
output_path.write_text("\n".join(lines))
# ── input handling ─────────────────────────────────────────────────────
@staticmethod
def _unpack(models) -> tuple:
"""Accept the shapes the other generators accept, plus datasets."""
if isinstance(models, tuple):
model_defs = list(models[0])
enum_defs = list(models[1]) if len(models) > 1 else []
datasets = list(models[2]) if len(models) > 2 else []
return model_defs, enum_defs, datasets
if hasattr(models, "models"):
# SchemaLoader
model_defs = list(models.models) + list(getattr(models, "api_models", []))
return model_defs, list(getattr(models, "enums", [])), []
if isinstance(models, list):
return list(models), [], []
raise ValueError(f"Unsupported input type: {type(models)}")
@staticmethod
def _derive_class_name(output_path: Path) -> str:
stem = output_path.stem.replace("datagen_", "").replace("_datagen", "")
parts = [p for p in stem.replace("-", "_").split("_") if p]
base = "".join(p[:1].upper() + p[1:] for p in parts) or "Generated"
return f"{base}Generator"
# ── emission ───────────────────────────────────────────────────────────
def _header(self, class_name: str, seeded: bool) -> List[str]:
lines = [
'"""',
"Data generator - GENERATED FILE",
"",
"Do not edit directly. Regenerate using modelgen (target: datagen).",
'"""',
"",
"import json",
"import random",
"import uuid",
"from datetime import datetime, timedelta, timezone",
"from pathlib import Path",
"",
"try:",
" from station.tools.datagen.base import BaseDataGenerator",
"except ImportError: # pragma: no cover - standalone use",
_FALLBACK_BASE.strip("\n"),
"",
_HEADER_HELPERS.strip("\n"),
"",
"",
f"class {class_name}(BaseDataGenerator):",
' """Generated from a modelgen schema."""',
"",
]
if seeded:
lines.extend([
f' _DEPOT = Path(__file__).parent / "{self.depot}"',
"",
" def __init__(self):",
" self._seed = {}",
" if self._DEPOT.exists():",
" try:",
" self._seed = json.loads(self._DEPOT.read_text())",
" except (OSError, ValueError):",
" self._seed = {}",
"",
])
return lines
def _model_method(
self, model_def: ModelDefinition, model_names: set, seeded: set
) -> List[str]:
mapped = self.map_name(model_def.name)
doc = (model_def.docstring or mapped).strip().splitlines()[0]
lines = [
f" def {mapped.lower()}(self, **kwargs) -> dict:",
f' """{doc}"""',
]
if model_def.name in seeded:
# Real rows first; synthesis is the fallback for when the depot is
# missing or a caller asked for more rows than were imported.
lines.append(f' record = self._sample("{model_def.name}")')
lines.append(" if record is None:")
indent = " "
else:
indent = " "
if model_def.fields:
lines.append(f"{indent}record = {{")
for field in model_def.fields:
value = self._value(field, model_names)
lines.append(f'{indent} "{field.name}": {value},')
lines.append(f"{indent}}}")
else:
lines.append(f"{indent}record = {{}}")
lines.append(" record.update(kwargs)")
lines.append(" return record")
return lines
def _value(self, field: Any, model_names: set) -> str:
base, _ = unwrap_optional(field.type_hint)
name = field.name.lower()
if getattr(field, "primary_key", False):
if base in (int, "bigint"):
return "self._next_id()"
return "str(uuid.uuid4())"
fk = getattr(field, "foreign_key", None)
if fk:
target = self.map_name(fk)
# How a relation is carried depends on the shape it was found in.
# A `customer_id` column holds a key; a `category` property in a
# spec holds the object itself, and answering that one with a key
# would be the wrong shape, not merely a dull value.
if base == "dict":
return f'self._nested("{target}")'
if base == "list":
return f'self._nested_list("{target}")'
if base in (int, "bigint"):
# A plausible existing key, not a fresh one: a foreign key that
# never matches anything makes the mock useless for joins.
return "random.randint(1, 100)"
return "str(uuid.uuid4())"
if isinstance(base, type) and issubclass(base, Enum):
values = json.dumps([m.value for m in base])
return f"random.choice({values})"
if base is bool:
return "random.choice([True, False])"
if base in (int, "bigint"):
return "random.randint(1, 10000)"
if base is float:
return "round(random.uniform(1, 10000), 2)"
if base == "UUID":
return "str(uuid.uuid4())"
if base == "datetime":
return "self._recent()"
if base == "dict":
return "{}"
if base == "list":
return "[]"
if base == "bytes":
return '""'
for hint, expression in _NAME_HINTS:
if hint in name:
return expression
return 'random.choice(_WORDS) + "-" + str(random.randint(1, 999))'
def _schema_method(
self, model_defs: List[ModelDefinition], model_names: set
) -> List[str]:
schema: Dict[str, Any] = {"models": {}}
for model_def in model_defs:
mapped = self.map_name(model_def.name)
entry: Dict[str, Any] = {}
if model_def.docstring:
entry["doc"] = model_def.docstring.strip().splitlines()[0]
fields: Dict[str, Any] = {}
for field in model_def.fields:
fields[field.name] = self._schema_field(field, model_names)
entry["fields"] = fields
schema["models"][mapped] = entry
# pformat, not json.dumps: the result is spliced into a source file, so
# it has to be a Python literal. Post-processing JSON with string
# replacement would rewrite "true" inside a docstring too.
body = pformat(schema, indent=4, width=88, sort_dicts=False)
indented = "\n".join(f" {line}" for line in body.splitlines())
return [
" def schema(self) -> dict:",
' """Graphgen-compatible schema — surfaced at /tools/datagen/api/schema."""',
f" return {indented.lstrip()}",
]
def _schema_field(self, field: Any, model_names: set) -> Dict[str, Any]:
base, is_optional = unwrap_optional(field.type_hint)
fk = getattr(field, "foreign_key", None)
if fk:
relation = "M2M" if getattr(field, "many", False) else "FK"
type_value = f"{relation}:{self.map_name(fk)}"
elif isinstance(base, type) and issubclass(base, Enum):
type_value = base.__name__
elif isinstance(base, str):
type_value = base
elif hasattr(base, "__name__"):
type_value = base.__name__
else:
type_value = "Any"
out: Dict[str, Any] = {
"type": type_value,
"nullable": bool(getattr(field, "optional", False) or is_optional),
}
if getattr(field, "primary_key", False):
out["pk"] = True
if getattr(field, "unique", False):
out["unique"] = True
return out
@staticmethod
def _helpers(seeded: bool) -> List[str]:
lines = [
" # ── helpers ────────────────────────────────────────────────",
"",
" _counter = 0",
"",
" def _next_id(self) -> int:",
' """Monotonic ids, so generated relations can be joined."""',
" type(self)._counter += 1",
" return type(self)._counter",
"",
" @staticmethod",
" def _recent() -> str:",
' """An ISO timestamp within the last 90 days."""',
" moment = datetime.now(timezone.utc) - timedelta(",
" days=random.randint(0, 90), seconds=random.randint(0, 86399)",
" )",
" return moment.isoformat()",
"",
" # Nesting is capped rather than followed: schemas refer to each",
" # other in cycles, and an uncapped expansion recurses forever.",
" _depth = 0",
" _MAX_DEPTH = 2",
"",
" def _nested(self, model: str):",
' """One related object, or None once the depth cap is reached."""',
" if type(self)._depth >= self._MAX_DEPTH:",
" return None",
" method = getattr(self, model.lower(), None)",
" if not callable(method):",
" return None",
" type(self)._depth += 1",
" try:",
" return method()",
" finally:",
" type(self)._depth -= 1",
"",
" def _nested_list(self, model: str, count: int = 2) -> list:",
' """A short list of related objects."""',
" items = [self._nested(model) for _ in range(count)]",
" return [item for item in items if item is not None]",
]
if seeded:
lines.extend([
"",
" def _sample(self, model: str):",
' """A copy of a real imported row, or None if there are none."""',
" rows = self._seed.get(model) or []",
" if not rows:",
" return None",
" return dict(random.choice(rows))",
])
lines.append("")
return lines

View File

@@ -0,0 +1,117 @@
"""
JSON Schema Generator
Emits a graphgen-compatible ``schema.json`` — the canonical, portable schema
"source" artifact that downstream tools (graphgen, databrowse) read directly.
Format (consumed by graphgen/schema.py::_load_json_schema):
{
"models": {
"Users": {
"doc": "...",
"fields": {
"id": {"type": "int", "pk": true, "nullable": false},
"name": {"type": "str", "nullable": false}
}
},
"Posts": {
"fields": {
"user_id": {"type": "FK:Users", "nullable": false}
}
}
}
}
"""
import json
from enum import Enum
from pathlib import Path
from typing import Any, List
from ..helpers import unwrap_optional
from ..loader.schema import EnumDefinition, ModelDefinition
from .base import BaseGenerator
class JsonSchemaGenerator(BaseGenerator):
"""Generates a graphgen-compatible schema.json from model definitions."""
def file_extension(self) -> str:
return ".json"
def generate(self, models, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
if hasattr(models, "models"):
# SchemaLoader
model_defs = list(models.models) + list(getattr(models, "api_models", []))
elif isinstance(models, tuple):
# (models, enums) tuple
model_defs = list(models[0])
elif isinstance(models, list):
model_defs = list(models)
else:
raise ValueError(f"Unsupported input type: {type(models)}")
model_names = {self.map_name(m.name) for m in model_defs}
out = {"models": {}}
for model_def in model_defs:
out["models"][self.map_name(model_def.name)] = self._model(
model_def, model_names
)
output_path.write_text(json.dumps(out, indent=2) + "\n")
def _model(self, model_def: ModelDefinition, model_names: set) -> dict:
entry: dict = {}
if getattr(model_def, "docstring", None):
entry["doc"] = model_def.docstring.strip().splitlines()[0]
fields: dict = {}
for field in model_def.fields:
fields[field.name] = self._field(field, model_names)
entry["fields"] = fields
return entry
def _field(self, field: Any, model_names: set) -> dict:
base, is_opt = unwrap_optional(field.type_hint)
nullable = bool(getattr(field, "optional", False) or is_opt)
fk_target = getattr(field, "foreign_key", None)
type_str = self._type_str(base)
# Resolve the relationship-aware type string graphgen expects.
if fk_target:
relation = "M2M" if getattr(field, "many", False) else "FK"
type_value = f"{relation}:{self.map_name(fk_target)}"
elif type_str in model_names:
type_value = f"FK:{type_str}"
elif type_str == "M2M":
type_value = "M2M"
else:
type_value = type_str
out: dict = {"type": type_value, "nullable": nullable}
if getattr(field, "primary_key", False):
out["pk"] = True
if getattr(field, "unique", False):
out["unique"] = True
return out
@staticmethod
def _type_str(t: Any) -> str:
if t is None:
return "Any"
if isinstance(t, str):
return t
if isinstance(t, type) and issubclass(t, Enum):
return t.__name__
if hasattr(t, "__name__"):
return t.__name__
return str(t)
# Backwards/alternate name used by the registry alias.
SchemaGenerator = JsonSchemaGenerator

View File

@@ -27,8 +27,9 @@ class ProtobufGenerator(BaseGenerator):
if hasattr(models, "grpc_messages"):
# SchemaLoader with gRPC definitions
content = self._generate_from_loader(models)
elif isinstance(models, tuple) and len(models) >= 3:
# (messages, service_def) tuple
elif isinstance(models, tuple) and len(models) >= 2:
# (models, enums, ...) tuple — same first two slots as every other
# generator; extra slots (datasets) belong to targets that use them.
content = self._generate_from_definitions(models[0], models[1])
elif isinstance(models, list):
# List of dataclasses (MPR style)

View File

@@ -8,8 +8,16 @@ Supported loaders:
"""
from .config import ConfigLoader, load_config
from .extract import EXTRACTORS, BaseExtractor, DjangoExtractor
from .extract import (
EXTRACTORS,
BaseExtractor,
DjangoExtractor,
OpenAPIExtractor,
TabularExtractor,
)
from .schema import (
DatasetDefinition,
EndpointDefinition,
EnumDefinition,
FieldDefinition,
GrpcServiceDefinition,
@@ -30,8 +38,12 @@ __all__ = [
"FieldDefinition",
"EnumDefinition",
"GrpcServiceDefinition",
"EndpointDefinition",
"DatasetDefinition",
# Extractors
"BaseExtractor",
"DjangoExtractor",
"OpenAPIExtractor",
"TabularExtractor",
"EXTRACTORS",
]

View File

@@ -1,20 +1,41 @@
"""
Extractors - Extract model definitions from existing codebases.
Supported frameworks:
Supported sources:
- Django: Extract from Django ORM models
- SQLAlchemy: Extract from SQLAlchemy models (planned)
- Prisma: Extract from Prisma schema (planned)
- SQLAlchemy: Extract from SQLAlchemy models
- OpenAPI: Extract from an OpenAPI 3.x / Swagger 2.0 document
- Tabular: Extract from a directory of CSV/TSV/ODS spreadsheets
"""
from typing import Dict, Type
from .base import BaseExtractor
from .django import DjangoExtractor
from .openapi import OpenAPIExtractor
from .sqlalchemy_models import SqlAlchemyExtractor
from .tabular import TabularExtractor
# Registry of available extractors
# Registry of source extractors (auto-detectable via detect()).
#
# Ordering matters for `--framework auto`: detection runs in insertion order and
# stops at the first match, so the two that inspect a *file* come after the two
# that inspect a *source tree* and cannot be confused with them.
#
# Note: live-database introspection lives in database.py (DatabaseExtractor),
# invoked explicitly via the `from-db` command since it takes a URL, not a path.
EXTRACTORS: Dict[str, Type[BaseExtractor]] = {
"django": DjangoExtractor,
"sqlalchemy": SqlAlchemyExtractor,
"openapi": OpenAPIExtractor,
"tabular": TabularExtractor,
}
__all__ = ["BaseExtractor", "DjangoExtractor", "EXTRACTORS"]
__all__ = [
"BaseExtractor",
"DjangoExtractor",
"SqlAlchemyExtractor",
"OpenAPIExtractor",
"TabularExtractor",
"EXTRACTORS",
]

View File

@@ -0,0 +1,192 @@
"""
Database Extractor
All-terrain DDL extractor: reflects a live database via SQLAlchemy's Inspector
and produces modelgen's intermediate representation (ModelDefinition / EnumDefinition).
Works across any dialect SQLAlchemy supports (PostgreSQL, MySQL, SQLite, ...) —
the dialect is abstracted by the connection URL.
SQLAlchemy is an optional dependency (it is imported lazily) so that core modelgen
stays pure-stdlib and standalone. Install with: pip install "sqlalchemy>=2.0"
(plus a driver for non-sqlite dialects, e.g. psycopg2 / pymysql).
Example:
extractor = DatabaseExtractor("sqlite:////tmp/test.db")
models, enums = extractor.extract()
"""
from typing import Any, List, Optional
from ..schema import EnumDefinition, FieldDefinition, ModelDefinition
_INSTALL_HINT = (
"DatabaseExtractor requires SQLAlchemy. Install it with:\n"
' pip install "sqlalchemy>=2.0"\n'
"(plus a driver for your dialect, e.g. psycopg2 for PostgreSQL, pymysql for MySQL; "
"sqlite needs none)."
)
def _to_model_name(table_name: str) -> str:
"""Convert a table name to a PascalCase model name (users -> Users)."""
parts = [p for p in table_name.replace("-", "_").split("_") if p]
return "".join(p[:1].upper() + p[1:] for p in parts) or table_name
def _map_column_type(col_type: Any) -> tuple[Any, Optional[str]]:
"""Map a SQLAlchemy column type to an IR type hint.
Returns (type_hint, enum_name). type_hint is either a Python type or one of
modelgen's special string names (see types.py). enum_name is set only for
enum columns, so the caller can register/reference the enum.
"""
import sqlalchemy as sa
# Enum (named DB enum, e.g. Postgres ENUM, or SQLAlchemy Enum)
if isinstance(col_type, sa.Enum):
name = col_type.name or "Enum"
return _to_model_name(name), name
# Dialect-specific types are matched by class name (UUID, JSONB, ARRAY, ...)
tname = type(col_type).__name__.upper()
if "UUID" in tname:
return "UUID", None
if "JSON" in tname: # JSON, JSONB
return "dict", None
if "ARRAY" in tname:
return "list", None
# Generic types — most specific first (subclass relationships matter).
if isinstance(col_type, sa.Boolean):
return bool, None
if isinstance(col_type, sa.BigInteger):
return "bigint", None
if isinstance(col_type, (sa.SmallInteger, sa.Integer)):
return int, None
if isinstance(col_type, (sa.Numeric, sa.Float)):
return float, None
if isinstance(col_type, sa.Text):
return "text", None
if isinstance(col_type, sa.String):
return str, None
if isinstance(col_type, (sa.DateTime, sa.Date, sa.Time)):
return "datetime", None
if isinstance(col_type, sa.LargeBinary):
return "bytes", None
# Fallback: try the type's declared python_type.
try:
py = col_type.python_type
return {str: str, int: int, float: float, bool: bool}.get(py, str), None
except Exception:
return str, None
class DatabaseExtractor:
"""Reflects a live database into modelgen's IR via SQLAlchemy."""
def __init__(
self,
url: str,
schema: Optional[str] = None,
include: Optional[set] = None,
exclude: Optional[set] = None,
):
self.url = url
self.schema = schema
self.include = include
self.exclude = exclude or set()
def extract(self) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
try:
import sqlalchemy as sa
except ImportError as e: # pragma: no cover - exercised only without the extra
raise RuntimeError(_INSTALL_HINT) from e
engine = sa.create_engine(self.url)
inspector = sa.inspect(engine)
table_names = inspector.get_table_names(schema=self.schema)
if self.include:
table_names = [t for t in table_names if t in self.include]
table_names = [t for t in table_names if t not in self.exclude]
models: List[ModelDefinition] = []
enums: dict[str, EnumDefinition] = {}
for table in table_names:
models.append(self._extract_table(inspector, table, enums))
engine.dispose()
return models, list(enums.values())
def _extract_table(
self, inspector: Any, table: str, enums: dict
) -> ModelDefinition:
columns = inspector.get_columns(table, schema=self.schema)
# Primary key columns
try:
pk_cols = set(
inspector.get_pk_constraint(table, schema=self.schema).get(
"constrained_columns", []
)
or []
)
except Exception:
pk_cols = set()
# Single-column unique constraints
unique_cols: set = set()
try:
for uc in inspector.get_unique_constraints(table, schema=self.schema):
cols = uc.get("column_names", []) or []
if len(cols) == 1:
unique_cols.add(cols[0])
except Exception:
pass
# Foreign keys: constrained column -> referred model name
fk_targets: dict = {}
try:
for fk in inspector.get_foreign_keys(table, schema=self.schema):
referred = fk.get("referred_table")
for col in fk.get("constrained_columns", []) or []:
if referred:
fk_targets[col] = _to_model_name(referred)
except Exception:
pass
fields: List[FieldDefinition] = []
for col in columns:
name = col["name"]
type_hint, enum_name = _map_column_type(col["type"])
if enum_name and enum_name not in enums:
values = list(getattr(col["type"], "enums", []) or [])
enums[enum_name] = EnumDefinition(
name=_to_model_name(enum_name),
values=[(v, v) for v in values],
)
fk_target = fk_targets.get(name)
is_pk = name in pk_cols
# Keep the scalar column type as the type hint; the relationship is
# carried by foreign_key metadata (downstream consumers like graphgen
# read that, so non-graph targets keep the correct scalar type).
optional = bool(col.get("nullable", True)) and not is_pk
fields.append(
FieldDefinition(
name=name,
type_hint=type_hint,
default=col.get("default"),
optional=optional,
primary_key=is_pk,
foreign_key=fk_target,
unique=name in unique_cols,
)
)
return ModelDefinition(name=_to_model_name(table), fields=fields)

View File

@@ -0,0 +1,440 @@
"""
OpenAPI Extractor
Reads an OpenAPI 3.x or Swagger 2.0 document and produces modelgen's IR.
One parse yields two products:
extractor = OpenAPIExtractor("api.yaml")
models, enums = extractor.extract() # the shapes (BaseExtractor contract)
endpoints = extractor.endpoints() # the routes (what shuntgen consumes)
Only the first is part of the BaseExtractor contract; a spec is the one input
that describes calls as well as shapes, so endpoints() is an addition rather
than a widening of the ABC.
Referenced schemas become foreign_key metadata rather than nested types, which
is the same call DatabaseExtractor makes (see database.py) and for the same
reason: it keeps every generator emitting valid code, and graphgen still draws
the edge. Enums are materialised as real Enum classes so the existing
PYDANTIC_RESOLVERS/TS_RESOLVERS "enum" branch resolves them by name.
YAML is optional. JSON specs parse with the stdlib alone — which is what keeps
modelgen installable with no dependencies — and a .yaml spec needs PyYAML.
"""
import json
import re
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from ..schema import EndpointDefinition, EnumDefinition, FieldDefinition, ModelDefinition
from .base import BaseExtractor
_YAML_HINT = (
"Reading a YAML spec requires PyYAML. Install it with:\n"
" pip install pyyaml\n"
"(JSON specs need nothing beyond the standard library.)"
)
# OpenAPI `format` is more specific than `type`, so it wins where both are set.
_FORMAT_HINTS: Dict[str, Any] = {
"date-time": "datetime",
"date": "datetime",
"uuid": "UUID",
"binary": "bytes",
"byte": "bytes",
"int64": "bigint",
}
_TYPE_HINTS: Dict[str, Any] = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": "list",
"object": "dict",
}
# Response codes worth reading a body off, best first. "default" is last
# because a spec that defines it usually means the error case.
_STATUS_PREFERENCE = ("200", "201", "2XX", "202", "203", "204", "default")
def _to_model_name(name: str) -> str:
"""Convert a schema name to PascalCase (pet_category -> PetCategory)."""
parts = [p for p in re.split(r"[^0-9a-zA-Z]+", name) if p]
return "".join(p[:1].upper() + p[1:] for p in parts) or name
def _to_member_name(value: Any) -> str:
"""Convert an enum value to a legal Python identifier."""
name = re.sub(r"[^0-9a-zA-Z]+", "_", str(value)).strip("_").upper()
if not name:
return "EMPTY"
if name[0].isdigit():
return f"V_{name}"
return name
def _load_document(path: Path) -> dict:
"""Parse a spec file. JSON via stdlib; YAML only if PyYAML is installed."""
text = path.read_text()
if path.suffix.lower() in (".yaml", ".yml"):
try:
import yaml
except ImportError as e: # pragma: no cover - exercised only without the extra
raise RuntimeError(_YAML_HINT) from e
return yaml.safe_load(text) or {}
try:
return json.loads(text)
except ValueError:
# A spec is often handed over with no extension or a wrong one, so fall
# back to YAML rather than failing on what is really a naming mistake.
try:
import yaml
except ImportError as e:
raise ValueError(
f"{path} is not valid JSON, and YAML support is unavailable.\n{_YAML_HINT}"
) from e
return yaml.safe_load(text) or {}
class OpenAPIExtractor(BaseExtractor):
"""Extracts modelgen IR from an OpenAPI 3.x / Swagger 2.0 document."""
def __init__(self, source_path):
super().__init__(source_path)
self._doc: Optional[dict] = None
self._enums: Dict[str, EnumDefinition] = {}
self._enum_types: Dict[str, type] = {}
# ── document access ────────────────────────────────────────────────────
@property
def doc(self) -> dict:
if self._doc is None:
self._doc = _load_document(self.source_path)
return self._doc
@property
def is_swagger2(self) -> bool:
return "swagger" in self.doc and "openapi" not in self.doc
def _schemas(self) -> Dict[str, dict]:
"""Named schemas, wherever this spec version keeps them."""
if self.is_swagger2:
return self.doc.get("definitions", {}) or {}
return (self.doc.get("components", {}) or {}).get("schemas", {}) or {}
def detect(self) -> bool:
"""True when the source is a parseable document declaring a spec version."""
if not self.source_path.is_file():
return False
if self.source_path.suffix.lower() not in (".json", ".yaml", ".yml"):
return False
try:
doc = self.doc
except Exception:
return False
return isinstance(doc, dict) and ("openapi" in doc or "swagger" in doc)
# ── $ref plumbing ──────────────────────────────────────────────────────
@staticmethod
def _ref_name(ref: str) -> str:
return _to_model_name(ref.rsplit("/", 1)[-1])
def _deref(self, schema: Any, _seen: Optional[set] = None) -> dict:
"""Follow $ref chains to the schema they land on."""
if not isinstance(schema, dict):
return {}
seen = _seen or set()
ref = schema.get("$ref")
if not ref or ref in seen:
return schema
seen.add(ref)
target = self.doc
for part in ref.lstrip("#/").split("/"):
if not isinstance(target, dict):
return {}
target = target.get(part, {})
return self._deref(target, seen)
def _flatten(self, schema: dict) -> Tuple[Dict[str, dict], List[str]]:
"""Resolve allOf/$ref into one property map plus the required names."""
schema = self._deref(schema)
props: Dict[str, dict] = {}
required: List[str] = []
for sub in schema.get("allOf", []) or []:
sub_props, sub_required = self._flatten(sub)
props.update(sub_props)
required.extend(sub_required)
# anyOf/oneOf describe alternatives, not a single shape. Taking the
# first branch's properties beats emitting an empty model — a shunt
# answering with one valid variant is more useful than one answering {}.
for key in ("oneOf", "anyOf"):
for sub in (schema.get(key) or [])[:1]:
sub_props, sub_required = self._flatten(sub)
props.update(sub_props)
required.extend(sub_required)
props.update(schema.get("properties", {}) or {})
required.extend(schema.get("required", []) or [])
return props, required
# ── type resolution ────────────────────────────────────────────────────
def _register_enum(self, owner: str, field_name: str, values: List[Any]) -> type:
"""Materialise a spec enum as a real Enum class, deduped by value set."""
name = f"{_to_model_name(owner)}{_to_model_name(field_name)}"
pairs = [(_to_member_name(v), str(v)) for v in values]
# Two fields can legitimately declare the same enum name with different
# members; suffix rather than let the second silently win.
existing = self._enums.get(name)
if existing and existing.values != pairs:
suffix = 2
while f"{name}{suffix}" in self._enums and (
self._enums[f"{name}{suffix}"].values != pairs
):
suffix += 1
name = f"{name}{suffix}"
if name not in self._enums:
self._enums[name] = EnumDefinition(name=name, values=pairs)
self._enum_types[name] = Enum(name, pairs)
return self._enum_types[name]
def _resolve(
self, owner: str, field_name: str, prop: dict
) -> Tuple[Any, Optional[str], bool]:
"""Map a property schema to (type_hint, foreign_key, many)."""
ref = prop.get("$ref")
if ref:
target = self._ref_name(ref)
resolved = self._deref(prop)
# A $ref to an enum or a plain scalar is not a relationship — only
# a ref to an object shape is.
if resolved.get("enum"):
return self._register_enum(owner, field_name, resolved["enum"]), None, False
if resolved.get("type") in _TYPE_HINTS and resolved.get("type") != "object":
return self._resolve(owner, field_name, resolved)
return "dict", target, False
if prop.get("enum") and prop.get("type", "string") == "string":
return self._register_enum(owner, field_name, prop["enum"]), None, False
if prop.get("allOf") or prop.get("oneOf") or prop.get("anyOf"):
branch = (
(prop.get("allOf") or prop.get("oneOf") or prop.get("anyOf")) or [{}]
)[0]
if branch.get("$ref"):
return "dict", self._ref_name(branch["$ref"]), False
return self._resolve(owner, field_name, self._deref(branch))
prop_type = prop.get("type")
if prop_type == "array":
items = prop.get("items", {}) or {}
if items.get("$ref"):
inner = self._deref(items)
if inner.get("type", "object") == "object":
return "list", self._ref_name(items["$ref"]), True
return "list", None, False
fmt = prop.get("format")
if fmt in _FORMAT_HINTS:
return _FORMAT_HINTS[fmt], None, False
return _TYPE_HINTS.get(prop_type, str), None, False
# ── extraction ─────────────────────────────────────────────────────────
def extract(self) -> Tuple[List[ModelDefinition], List[EnumDefinition]]:
self._enums = {}
self._enum_types = {}
models: List[ModelDefinition] = []
for raw_name, schema in self._schemas().items():
model_name = _to_model_name(raw_name)
schema = schema or {}
# A top-level enum is a type, not a shape — register it and skip.
if schema.get("enum") and not schema.get("properties"):
self._register_enum(model_name, "", schema["enum"])
continue
props, required = self._flatten(schema)
required_set = set(required)
fields: List[FieldDefinition] = []
for prop_name, prop in props.items():
prop = prop or {}
type_hint, fk, many = self._resolve(model_name, prop_name, prop)
is_pk = prop_name == "id" or prop_name == f"{raw_name.lower()}_id"
fields.append(
FieldDefinition(
name=prop_name,
type_hint=type_hint,
default=prop.get("default"),
optional=prop_name not in required_set and not is_pk,
primary_key=is_pk,
foreign_key=fk,
many=many,
)
)
models.append(
ModelDefinition(
name=model_name,
fields=fields,
docstring=schema.get("description") or schema.get("title"),
)
)
return models, list(self._enums.values())
# ── endpoints ──────────────────────────────────────────────────────────
def endpoints(self) -> List[EndpointDefinition]:
"""Every operation in the document, normalised for routing."""
out: List[EndpointDefinition] = []
base = self.doc.get("basePath", "") if self.is_swagger2 else ""
for path, item in (self.doc.get("paths", {}) or {}).items():
if not isinstance(item, dict):
continue
shared_params = item.get("parameters", []) or []
for method, op in item.items():
if method.lower() not in (
"get", "post", "put", "patch", "delete", "head", "options"
):
continue
if not isinstance(op, dict):
continue
status, response_schema, example = self._response(op)
request_model = self._request_model(op, shared_params)
response_model, is_list, envelope = self._schema_target(response_schema)
full_path = f"{base.rstrip('/')}{path}" if base else path
model = response_model or request_model
if is_list:
kind = "collection"
elif re.search(r"\{[^}]+\}$", full_path):
kind = "item"
elif method.lower() == "post" and model:
kind = "collection"
else:
kind = "action"
out.append(
EndpointDefinition(
method=method.upper(),
path=full_path,
operation_id=op.get("operationId"),
summary=op.get("summary") or op.get("description"),
kind=kind,
model=model,
request_model=request_model,
response_model=response_model,
response_is_list=is_list,
envelope_key=envelope,
status=status,
path_params=re.findall(r"\{([^}]+)\}", full_path),
example=example,
)
)
return out
def _response(self, op: dict) -> Tuple[int, dict, Any]:
"""Pick the success response and return (status, schema, example)."""
responses = op.get("responses", {}) or {}
codes = [str(c) for c in responses]
chosen = next((c for c in _STATUS_PREFERENCE if c in codes), None)
if chosen is None:
chosen = next((c for c in sorted(codes) if c.startswith("2")), None)
if chosen is None:
return 200, {}, None
body = responses.get(chosen) or responses.get(int(chosen), {}) or {}
status = 200 if chosen in ("default", "2XX") else int(chosen)
if self.is_swagger2:
return status, body.get("schema", {}) or {}, body.get("examples")
content = body.get("content", {}) or {}
media = content.get("application/json") or next(
(v for k, v in content.items() if "json" in k), {}
)
example = media.get("example")
if example is None:
examples = media.get("examples") or {}
first = next(iter(examples.values()), None)
if isinstance(first, dict):
example = first.get("value")
return status, media.get("schema", {}) or {}, example
def _request_model(self, op: dict, shared_params: list) -> Optional[str]:
if self.is_swagger2:
params = list(shared_params) + list(op.get("parameters", []) or [])
for param in params:
if isinstance(param, dict) and param.get("in") == "body":
name, _, _ = self._schema_target(param.get("schema", {}) or {})
return name
return None
body = op.get("requestBody") or {}
content = self._deref(body).get("content", {}) or {}
media = content.get("application/json") or next(
(v for k, v in content.items() if "json" in k), {}
)
name, _, _ = self._schema_target(media.get("schema", {}) or {})
return name
def _schema_target(
self, schema: dict
) -> Tuple[Optional[str], bool, Optional[str]]:
"""Return (model name, is_list, envelope key) for a request/response schema."""
if not isinstance(schema, dict) or not schema:
return None, False, None
if schema.get("$ref"):
resolved = self._deref(schema)
# A named wrapper (PetPage) is still a wrapper; look through it.
if resolved.get("type") == "array" or resolved.get("properties"):
inner = self._schema_target(resolved)
if inner[0]:
return inner
return self._ref_name(schema["$ref"]), False, None
if schema.get("type") == "array":
items = schema.get("items", {}) or {}
if items.get("$ref"):
return self._ref_name(items["$ref"]), True, None
return None, True, None
# A wrapped collection — {"items": [...], "total": n} and friends.
for prop_name, prop in (schema.get("properties", {}) or {}).items():
if prop_name in ("items", "results", "data") and (prop or {}).get(
"type"
) == "array":
inner = (prop.get("items") or {}).get("$ref")
if inner:
return self._ref_name(inner), True, prop_name
for key in ("allOf", "oneOf", "anyOf"):
for sub in (schema.get(key) or [])[:1]:
return self._schema_target(sub)
return None, False, None

View File

@@ -0,0 +1,217 @@
"""
SQLAlchemy Extractor
Extracts model definitions from SQLAlchemy declarative model *code* (not a live
database — see database.py for live introspection).
Pure AST parsing (no SQLAlchemy import needed), mirroring django.py. Detects
classes that declare ``__tablename__`` or inherit from a declarative ``Base`` /
``DeclarativeBase`` and parses their ``Column(...)`` assignments, including
``ForeignKey(...)`` relationships.
"""
import ast
from pathlib import Path
from typing import Dict, List, Optional
from ..schema import EnumDefinition, FieldDefinition, ModelDefinition
from .base import BaseExtractor
# SQLAlchemy column type names -> modelgen IR type hints.
SQLALCHEMY_TYPES = {
"Integer": int,
"SmallInteger": int,
"BigInteger": "bigint",
"String": str,
"Unicode": str,
"VARCHAR": str,
"Text": "text",
"UnicodeText": "text",
"Boolean": bool,
"Float": float,
"Numeric": float,
"DECIMAL": float,
"Date": "datetime",
"DateTime": "datetime",
"Time": "datetime",
"JSON": "dict",
"JSONB": "dict",
"UUID": "UUID",
"Uuid": "UUID",
"LargeBinary": "bytes",
"ARRAY": "list",
}
def _to_model_name(table_name: str) -> str:
parts = [p for p in table_name.replace("-", "_").split("_") if p]
return "".join(p[:1].upper() + p[1:] for p in parts) or table_name
class SqlAlchemyExtractor(BaseExtractor):
"""Extracts models from SQLAlchemy declarative model code."""
def detect(self) -> bool:
for py in self.source_path.rglob("*.py"):
try:
content = py.read_text()
except Exception:
continue
if "sqlalchemy" in content and (
"__tablename__" in content or "declarative_base" in content
or "DeclarativeBase" in content
):
return True
return False
def extract(self) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
# Pass 1: collect class -> __tablename__ so FK 'table.col' refs resolve
# back to the owning model class name.
class_nodes: List[ast.ClassDef] = []
for py in self.source_path.rglob("*.py"):
try:
tree = ast.parse(py.read_text())
except Exception:
continue
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and self._is_model(node):
class_nodes.append(node)
table_to_class: Dict[str, str] = {}
for node in class_nodes:
tablename = self._tablename(node)
if tablename:
table_to_class[tablename] = node.name
# Pass 2: build models.
models = [self._parse_model(node, table_to_class) for node in class_nodes]
return models, []
def _is_model(self, node: ast.ClassDef) -> bool:
if self._tablename(node):
return True
for base in node.bases:
if isinstance(base, ast.Name) and base.id in ("Base", "DeclarativeBase"):
return True
if isinstance(base, ast.Attribute) and base.attr in (
"Base",
"DeclarativeBase",
):
return True
return False
def _tablename(self, node: ast.ClassDef) -> Optional[str]:
for item in node.body:
if isinstance(item, ast.Assign):
for target in item.targets:
if (
isinstance(target, ast.Name)
and target.id == "__tablename__"
and isinstance(item.value, ast.Constant)
):
return str(item.value.value)
return None
def _parse_model(
self, node: ast.ClassDef, table_to_class: Dict[str, str]
) -> ModelDefinition:
fields: List[FieldDefinition] = []
for item in node.body:
field = None
if isinstance(item, ast.Assign):
if item.targets and isinstance(item.targets[0], ast.Name):
field = self._parse_column(
item.targets[0].id, item.value, table_to_class
)
elif isinstance(item, ast.AnnAssign) and isinstance(
item.target, ast.Name
):
field = self._parse_column(
item.target.id, item.value, table_to_class
)
if field:
fields.append(field)
return ModelDefinition(
name=node.name, fields=fields, docstring=ast.get_docstring(node)
)
def _parse_column(
self, name: str, value: ast.expr, table_to_class: Dict[str, str]
) -> Optional[FieldDefinition]:
if name.startswith("_"):
return None
if not isinstance(value, ast.Call):
return None
func_name = self._call_name(value)
# Support both `Column(...)` and 2.0-style `mapped_column(...)`.
if func_name not in ("Column", "mapped_column"):
return None
type_hint = str
fk_target: Optional[str] = None
# Positional args: a type (Name or Call) and/or a ForeignKey(...) call.
for arg in value.args:
if isinstance(arg, ast.Call) and self._call_name(arg) == "ForeignKey":
fk_target = self._foreign_key_target(arg, table_to_class)
elif isinstance(arg, ast.Name):
type_hint = SQLALCHEMY_TYPES.get(arg.id, str)
elif isinstance(arg, ast.Call):
inner = self._call_name(arg)
if inner == "ForeignKey":
fk_target = self._foreign_key_target(arg, table_to_class)
elif inner:
type_hint = SQLALCHEMY_TYPES.get(inner, str)
primary_key = False
nullable = True
unique = False
for kw in value.keywords:
if kw.arg == "primary_key" and isinstance(kw.value, ast.Constant):
primary_key = kw.value.value is True
elif kw.arg == "nullable" and isinstance(kw.value, ast.Constant):
nullable = kw.value.value is not False
elif kw.arg == "unique" and isinstance(kw.value, ast.Constant):
unique = kw.value.value is True
elif kw.arg == "ForeignKey" and isinstance(kw.value, ast.Call):
fk_target = self._foreign_key_target(kw.value, table_to_class)
# Primary keys are implicitly NOT NULL.
if primary_key:
nullable = False
# Keep the scalar column type; the relationship is carried by the
# foreign_key metadata (graphgen reads it; scalar targets stay correct).
return FieldDefinition(
name=name,
type_hint=type_hint,
default=None,
optional=nullable,
primary_key=primary_key,
foreign_key=fk_target,
unique=unique,
)
@staticmethod
def _call_name(call: ast.Call) -> Optional[str]:
if isinstance(call.func, ast.Name):
return call.func.id
if isinstance(call.func, ast.Attribute):
return call.func.attr
return None
@staticmethod
def _foreign_key_target(
call: ast.Call, table_to_class: Dict[str, str]
) -> Optional[str]:
if not call.args:
return None
arg = call.args[0]
if not isinstance(arg, ast.Constant) or not isinstance(arg.value, str):
return None
# "table.column" -> table -> owning model class name (or PascalCase table)
table = arg.value.split(".")[0]
return table_to_class.get(table, _to_model_name(table))

View File

@@ -0,0 +1,450 @@
"""
Tabular Extractor
Turns a directory of spreadsheets into modelgen's IR — one model per CSV file,
one per sheet inside an ODS workbook — and keeps the rows.
extractor = TabularExtractor("./sheets")
models, enums = extractor.extract() # the shapes (BaseExtractor contract)
datasets = extractor.datasets() # the rows (what shuntgen seeds with)
ODS is read with zipfile + ElementTree rather than odfpy or pandas. An .ods is
a zip with an XML part in it, and reading it directly is what lets modelgen keep
its "no dependencies" promise — the same promise that makes it publishable as a
standalone pip package.
Types are inferred per column from the values actually present, and blanks make
a column optional. Keys are inferred by name and confirmed by the data: an `id`
column that is neither unique nor complete is not treated as a primary key, and
a `customer_id` column is only a foreign key if a matching table exists.
"""
import csv
import re
import zipfile
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
from xml.etree import ElementTree
from ..schema import (
DatasetDefinition,
EnumDefinition,
FieldDefinition,
ModelDefinition,
)
from .base import BaseExtractor
SUFFIXES = (".csv", ".tsv", ".ods")
_TABLE = "{urn:oasis:names:tc:opendocument:xmlns:table:1.0}"
_OFFICE = "{urn:oasis:names:tc:opendocument:xmlns:office:1.0}"
# ODS pads every sheet out to the full grid with repeat counts in the thousands.
# Expanding those verbatim would turn a 5-column sheet into a 1024-column one,
# so repeats are honoured only up to a width a real sheet could plausibly have.
_MAX_COLS = 512
_MAX_ROWS = 100_000
_TRUE = {"true", "yes", "y", "t"}
_FALSE = {"false", "no", "n", "f"}
# Ordered widest-last: the first format that parses every value in the column
# wins, so a stricter pattern must be offered before a looser one.
_DATE_FORMATS = ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d")
_DATETIME_FORMATS = (
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
)
def _to_model_name(name: str) -> str:
"""Convert a file or sheet name to PascalCase (line_items -> LineItems)."""
parts = [p for p in re.split(r"[^0-9a-zA-Z]+", name) if p]
return "".join(p[:1].upper() + p[1:] for p in parts) or name
def _to_slug(name: str) -> str:
"""Convert a file or sheet name to a url-safe collection name."""
slug = re.sub(r"[^0-9a-zA-Z]+", "-", name).strip("-").lower()
return slug or "items"
def _singular(word: str) -> str:
"""Crude singulariser — enough to match a `customer_id` to a `customers` sheet."""
word = word.lower()
if word.endswith("ies") and len(word) > 3:
return word[:-3] + "y"
for ending in ("ches", "shes", "sses", "xes", "zes"):
if word.endswith(ending):
return word[: -len(ending) + 1]
if word.endswith("s") and not word.endswith("ss"):
return word[:-1]
return word
# ── readers ────────────────────────────────────────────────────────────────
def _read_delimited(path: Path) -> List[List[str]]:
"""Read a CSV/TSV into a grid of raw strings."""
delimiter = "\t" if path.suffix.lower() == ".tsv" else ","
with path.open(newline="", encoding="utf-8-sig") as fh:
return [row for row in csv.reader(fh, delimiter=delimiter)]
def _cell_text(cell: ElementTree.Element) -> str:
"""The displayed text of an ODS cell, typed value preferred over its label."""
value_type = cell.get(f"{_OFFICE}value-type")
for attr in ("value", "date-value", "time-value", "boolean-value"):
raw = cell.get(f"{_OFFICE}{attr}")
if raw is not None:
return raw
if value_type == "string" or value_type is None:
return "".join(cell.itertext()).strip()
return "".join(cell.itertext()).strip()
def _read_ods(path: Path) -> Dict[str, List[List[str]]]:
"""Read an ODS workbook into {sheet name: grid of raw strings}."""
with zipfile.ZipFile(path) as archive:
try:
content = archive.read("content.xml")
except KeyError as e:
raise ValueError(f"{path} is not a readable ODS file (no content.xml)") from e
root = ElementTree.fromstring(content)
sheets: Dict[str, List[List[str]]] = {}
for table in root.iter(f"{_TABLE}table"):
name = table.get(f"{_TABLE}name") or f"sheet{len(sheets) + 1}"
grid: List[List[str]] = []
for row in table.iter(f"{_TABLE}table-row"):
cells: List[str] = []
for cell in row:
if cell.tag not in (
f"{_TABLE}table-cell",
f"{_TABLE}covered-table-cell",
):
continue
repeat = int(cell.get(f"{_TABLE}number-columns-repeated", 1) or 1)
text = "" if cell.tag.endswith("covered-table-cell") else _cell_text(cell)
# Padding is always empty and always repeated; a real repeated
# value is worth expanding, an empty run at the end is not.
if not text and repeat > 1 and len(cells) + repeat > _MAX_COLS:
break
cells.extend([text] * min(repeat, _MAX_COLS - len(cells)))
if len(cells) >= _MAX_COLS:
break
while cells and not cells[-1].strip():
cells.pop()
row_repeat = int(row.get(f"{_TABLE}number-rows-repeated", 1) or 1)
if not cells:
# A repeated blank row is padding; a single one may be a gap.
if row_repeat == 1 and grid:
grid.append([])
continue
for _ in range(min(row_repeat, _MAX_ROWS - len(grid))):
grid.append(list(cells))
if grid:
sheets[name] = grid
return sheets
# ── inference ──────────────────────────────────────────────────────────────
def _parse_bool(value: str) -> Optional[bool]:
lowered = value.strip().lower()
if lowered in _TRUE:
return True
if lowered in _FALSE:
return False
return None
def _parse_int(value: str) -> Optional[int]:
text = value.strip()
# A float-shaped string must not read as an int, or a column of prices
# silently truncates.
if not re.fullmatch(r"[+-]?\d+", text):
return None
try:
return int(text)
except ValueError:
return None
def _parse_float(value: str) -> Optional[float]:
try:
return float(value.strip())
except ValueError:
return None
def _parse_uuid(value: str) -> Optional[str]:
try:
return str(UUID(value.strip()))
except (ValueError, AttributeError):
return None
def _parse_temporal(value: str) -> Optional[str]:
"""Parse a date/datetime into an ISO string, or None."""
text = value.strip()
if not text:
return None
for fmt in _DATETIME_FORMATS:
try:
return datetime.strptime(text, fmt).isoformat()
except ValueError:
continue
for fmt in _DATE_FORMATS:
try:
return datetime.strptime(text, fmt).date().isoformat()
except ValueError:
continue
# ODS writes ISO-8601 with a timezone or fractional seconds; let the
# stdlib parser have the ones the explicit formats miss.
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).isoformat()
except ValueError:
return None
# Each entry is (type hint, parser). Order is the precedence: the first type
# every value in the column satisfies wins, so narrow types come first.
_CANDIDATES: List[Tuple[Any, Any]] = [
(bool, _parse_bool),
(int, _parse_int),
(float, _parse_float),
("UUID", _parse_uuid),
("datetime", _parse_temporal),
]
def _infer_column(values: List[str]) -> Tuple[Any, Any]:
"""Return (type_hint, coercer) for a column, from its non-blank values."""
present = [v for v in values if v is not None and str(v).strip() != ""]
if not present:
return str, lambda v: v
for type_hint, parser in _CANDIDATES:
if all(parser(v) is not None for v in present):
return type_hint, parser
return str, lambda v: v.strip() if isinstance(v, str) else v
# ── extractor ──────────────────────────────────────────────────────────────
class TabularExtractor(BaseExtractor):
"""Extracts modelgen IR and seed rows from a directory of spreadsheets."""
def __init__(self, source_path):
super().__init__(source_path)
self._datasets: List[DatasetDefinition] = []
def detect(self) -> bool:
"""True when the source is a directory holding at least one sheet."""
if self.source_path.is_file():
return self.source_path.suffix.lower() in SUFFIXES
if not self.source_path.is_dir():
return False
return any(self._sources())
def _sources(self) -> List[Path]:
if self.source_path.is_file():
return [self.source_path]
return sorted(
p
for p in self.source_path.iterdir()
if p.is_file() and p.suffix.lower() in SUFFIXES and not p.name.startswith(".")
)
def datasets(self) -> List[DatasetDefinition]:
"""Seed rows harvested by the last extract() call."""
if not self._datasets:
self.extract()
return self._datasets
def extract(self) -> Tuple[List[ModelDefinition], List[EnumDefinition]]:
grids: List[Tuple[str, str, List[List[str]]]] = [] # (label, source, grid)
for path in self._sources():
if path.suffix.lower() == ".ods":
for sheet_name, grid in _read_ods(path).items():
grids.append((sheet_name, f"{path.name}#{sheet_name}", grid))
else:
grids.append((path.stem, path.name, _read_delimited(path)))
if not grids:
raise ValueError(f"No .csv/.tsv/.ods files found in {self.source_path}")
# Names have to be known before fields, because a foreign key is only a
# foreign key when the table it points at is one of the others.
known: Dict[str, str] = {}
for label, _, _ in grids:
model_name = _to_model_name(label)
known[_singular(label)] = model_name
known[label.lower()] = model_name
models: List[ModelDefinition] = []
self._datasets = []
for label, source, grid in grids:
parsed = self._build(label, source, grid, known)
if parsed:
model, dataset = parsed
models.append(model)
self._datasets.append(dataset)
return models, []
def _build(
self,
label: str,
source: str,
grid: List[List[str]],
known: Dict[str, str],
) -> Optional[Tuple[ModelDefinition, DatasetDefinition]]:
rows = [r for r in grid if any(str(c).strip() for c in r)]
if not rows:
return None
headers = self._headers(rows[0])
if not headers:
return None
body = rows[1:]
columns: Dict[str, List[str]] = {h: [] for h in headers}
for row in body:
for index, header in enumerate(headers):
columns[header].append(row[index] if index < len(row) else "")
model_name = _to_model_name(label)
pk = self._primary_key(label, model_name, headers, columns, len(body), known)
fields: List[FieldDefinition] = []
coercers: Dict[str, Any] = {}
for header in headers:
values = columns[header]
type_hint, coercer = _infer_column(values)
coercers[header] = coercer
is_pk = header == pk
fk = None if is_pk else self._foreign_key(header, model_name, known)
has_blank = any(str(v).strip() == "" for v in values)
fields.append(
FieldDefinition(
name=header,
type_hint=type_hint,
default=None,
optional=has_blank and not is_pk,
primary_key=is_pk,
foreign_key=fk,
unique=is_pk,
)
)
typed_rows: List[Dict[str, Any]] = []
for row in body:
record: Dict[str, Any] = {}
for index, header in enumerate(headers):
raw = row[index] if index < len(row) else ""
if str(raw).strip() == "":
record[header] = None
continue
parsed = coercers[header](raw)
record[header] = raw.strip() if parsed is None else parsed
typed_rows.append(record)
model = ModelDefinition(
name=model_name,
fields=fields,
docstring=f"Imported from {source} ({len(typed_rows)} rows).",
)
dataset = DatasetDefinition(
model=model_name,
rows=typed_rows,
source=source,
collection=_to_slug(label),
)
return model, dataset
@staticmethod
def _headers(row: List[str]) -> List[str]:
"""Normalise the header row, filling blanks and de-duplicating."""
headers: List[str] = []
seen: Dict[str, int] = {}
for index, raw in enumerate(row):
name = re.sub(r"[^0-9a-zA-Z]+", "_", str(raw).strip()).strip("_").lower()
if not name:
name = f"column_{index + 1}"
if name[0].isdigit():
name = f"c_{name}"
if name in seen:
seen[name] += 1
name = f"{name}_{seen[name]}"
else:
seen[name] = 1
headers.append(name)
return headers
@classmethod
def _primary_key(
cls,
label: str,
model_name: str,
headers: List[str],
columns: Dict[str, List[str]],
row_count: int,
known: Dict[str, str],
) -> Optional[str]:
"""Pick the key column — named like one, and unique and complete in fact."""
def holds(header: str) -> bool:
values = [str(v).strip() for v in columns[header]]
return bool(row_count) and all(values) and len(set(values)) == row_count
stem = _singular(label)
for candidate in ("id", f"{stem}_id", f"{label.lower()}_id"):
if candidate in headers and holds(candidate):
return candidate
# A sheet may name its key after the row rather than the table —
# `line_id` in a `line_items` sheet. Accept the leading column when it
# is shaped like a key, holds like one, and does not point elsewhere.
if headers:
first = headers[0]
if (
(first == "id" or first.endswith("_id"))
and cls._foreign_key(first, model_name, known) is None
and holds(first)
):
return first
return None
@staticmethod
def _foreign_key(
header: str, model_name: str, known: Dict[str, str]
) -> Optional[str]:
"""A `<thing>_id` column pointing at another sheet in the same import."""
if not header.endswith("_id"):
return None
stem = header[:-3]
target = known.get(_singular(stem)) or known.get(stem)
if target and target != model_name:
return target
return None

View File

@@ -13,7 +13,7 @@ Expects the folder to have an __init__.py that exports:
import dataclasses as dc
import importlib.util
import sys
from dataclasses import dataclass
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, get_type_hints
@@ -27,6 +27,15 @@ class FieldDefinition:
type_hint: Any
default: Any = dc.MISSING
optional: bool = False
# Optional DB/schema metadata (set by introspection extractors; ignored by
# loaders/generators that don't need it).
primary_key: bool = False
foreign_key: Optional[str] = None # target model name
unique: bool = False
# True when foreign_key points at many rows rather than one — an array of
# $ref, a m2m table. graphgen renders the two differently, so losing the
# distinction would draw every collection as a single edge.
many: bool = False
@dataclass
@@ -55,6 +64,51 @@ class GrpcServiceDefinition:
methods: List[Dict[str, Any]]
@dataclass
class EndpointDefinition:
"""Represents one HTTP operation on a service.
Models describe the shapes a service passes around; endpoints describe how
it is called. Loaders that read a service contract (OpenAPI) or infer one
(tabular) emit these alongside the models, and shuntgen turns them into
routes. Loaders that only see shapes — dataclasses, a Django app — emit
none, which is why this is not part of the BaseExtractor contract.
"""
method: str # GET, POST, ...
path: str # /pets/{petId}
operation_id: Optional[str] = None
summary: Optional[str] = None
# "collection" returns/accepts many, "item" one, "action" neither.
kind: str = "item"
model: Optional[str] = None # what this operation is about
request_model: Optional[str] = None
response_model: Optional[str] = None
response_is_list: bool = False
# Set when the collection arrives wrapped — {"items": [...], "total": n}
# rather than a bare array. Answering a wrapped endpoint with an array is
# the kind of mismatch a client only discovers at parse time.
envelope_key: Optional[str] = None
status: int = 200
path_params: List[str] = field(default_factory=list)
example: Any = None # response example carried through from the source
@dataclass
class DatasetDefinition:
"""Concrete rows harvested next to a model.
Importing a spreadsheet yields both a shape and the data that shaped it.
Throwing the rows away would mean generating a service that answers with
invented values when the real ones were right there.
"""
model: str
rows: List[Dict[str, Any]] = field(default_factory=list)
source: Optional[str] = None # file the rows came from
collection: Optional[str] = None # url-facing name, e.g. "customers"
class SchemaLoader:
"""Loads model definitions from Python dataclasses in schema/ folder."""

View File

@@ -4,11 +4,17 @@ build-backend = "setuptools.build_meta"
[project]
name = "soleprint-modelgen"
version = "0.2.0"
version = "0.3.0"
description = "Multi-source, multi-target model code generator"
requires-python = ">=3.10"
dependencies = []
# Optional extras. Core modelgen is pure-stdlib and standalone; live-database
# extraction (`from-db`) needs SQLAlchemy plus a driver for non-sqlite dialects
# (e.g. psycopg2 for PostgreSQL, pymysql for MySQL).
[project.optional-dependencies]
db = ["sqlalchemy>=2.0"]
[project.scripts]
modelgen = "modelgen.__main__:main"

View File

@@ -0,0 +1,18 @@
"""
Tests for modelgen.
Run from station/tools/, not from inside modelgen/ — the package ships a
types.py, and having its own directory on sys.path shadows the standard
library module of that name:
cd soleprint/station/tools
python -m unittest modelgen.tests.test_extractors
stdlib unittest only, and every input is built in a temp directory. modelgen is
published as a standalone pip package, so its tests have to pass with nothing
installed and nothing else in the tree.
These are unit tests of the loaders and generators. They are not contract
tests — those talk HTTP, belong to a room, and are never committed to core
(see station/tools/tester/tests/README.md).
"""

View File

@@ -0,0 +1,406 @@
"""
Tests for the OpenAPI and tabular extractors and the datagen target.
stdlib unittest and nothing else, and every input is built in a temp directory
rather than read from a fixture file — modelgen is published as a standalone
package, so its tests have to pass with nothing installed and nothing else in
the tree.
cd soleprint/station/tools && python -m unittest modelgen.tests.test_extractors
Run it from station/tools/, not from inside modelgen/: modelgen ships a
types.py, and putting the package's own directory on sys.path shadows the
standard library module of that name.
"""
import json
import shutil
import sys
import tempfile
import unittest
import zipfile
from enum import Enum
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1].parent))
from modelgen.generator import GENERATORS, DatagenGenerator # noqa: E402
from modelgen.loader.extract.openapi import OpenAPIExtractor # noqa: E402
from modelgen.loader.extract.tabular import TabularExtractor # noqa: E402
SPEC = {
"openapi": "3.0.3",
"info": {"title": "Zoo", "version": "1.0.0"},
"paths": {
"/pets": {
"get": {
"operationId": "listPets",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PetPage"}
}
}
}
},
},
"post": {
"operationId": "createPet",
"requestBody": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Pet"}
}
}
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Pet"}
}
}
}
},
},
},
"/pets/{petId}": {
"get": {
"operationId": "getPet",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/Pet"},
"example": {"id": 7, "name": "Rocinante"},
}
}
}
},
}
},
},
"components": {
"schemas": {
"Pet": {
"type": "object",
"description": "An animal.",
"required": ["id", "name"],
"properties": {
"id": {"type": "integer", "format": "int64"},
"name": {"type": "string"},
"status": {"type": "string", "enum": ["available", "sold"]},
"weight": {"type": "number"},
"born_on": {"type": "string", "format": "date"},
"category": {"$ref": "#/components/schemas/Category"},
"tags": {
"type": "array",
"items": {"$ref": "#/components/schemas/Category"},
},
},
},
"Category": {
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
},
},
"PetPage": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"$ref": "#/components/schemas/Pet"},
},
"total": {"type": "integer"},
},
},
}
},
}
CUSTOMERS_CSV = """id,name,email,active,joined,balance
1,Ada,ada@example.com,true,2024-03-11,150.5
2,Bruno,bruno@example.com,false,2024-05-02,80
3,Camila,camila@example.com,true,2023-11-27,
"""
ORDERS_CSV = """order_id,customer_id,total,placed
1001,1,42.00,2025-02-03
1002,2,15.25,2025-03-14
"""
def write_ods(path: Path, sheet: str, rows: list[list[str]]) -> None:
"""A minimal ODS, padded the way a real one is."""
ns = (
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" '
'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" '
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"'
)
def cell(value: str) -> str:
if value == "":
return "<table:table-cell/>"
return (
'<table:table-cell office:value-type="string">'
f"<text:p>{value}</text:p></table:table-cell>"
)
body = "".join(
"<table:table-row>"
+ "".join(cell(c) for c in row)
+ '<table:table-cell table:number-columns-repeated="1018"/>'
"</table:table-row>"
for row in rows
)
content = (
'<?xml version="1.0" encoding="UTF-8"?>'
f"<office:document-content {ns}>"
"<office:body><office:spreadsheet>"
f'<table:table table:name="{sheet}">{body}'
'<table:table-row table:number-rows-repeated="1048570">'
'<table:table-cell table:number-columns-repeated="1024"/></table:table-row>'
"</table:table></office:spreadsheet></office:body></office:document-content>"
)
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("content.xml", content)
class OpenAPIExtractorTest(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="modelgen-openapi-"))
self.spec = self.dir / "zoo.json"
self.spec.write_text(json.dumps(SPEC))
self.extractor = OpenAPIExtractor(self.spec)
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_detects_a_spec(self):
self.assertTrue(self.extractor.detect())
def test_does_not_detect_arbitrary_json(self):
other = self.dir / "not-a-spec.json"
other.write_text('{"hello": "world"}')
self.assertFalse(OpenAPIExtractor(other).detect())
def test_extracts_every_object_schema(self):
models, _ = self.extractor.extract()
self.assertEqual({m.name for m in models}, {"Pet", "Category", "PetPage"})
def test_enum_becomes_a_real_enum_class(self):
models, enums = self.extractor.extract()
# A materialised Enum is what makes every generator resolve it by name
# instead of falling through to str.
self.assertEqual([e.name for e in enums], ["PetStatus"])
status = next(f for f in models[0].fields if f.name == "status")
self.assertTrue(isinstance(status.type_hint, type))
self.assertTrue(issubclass(status.type_hint, Enum))
self.assertEqual([m.value for m in status.type_hint], ["available", "sold"])
def test_required_and_format_and_relations(self):
models, _ = self.extractor.extract()
fields = {f.name: f for f in next(m for m in models if m.name == "Pet").fields}
self.assertEqual(fields["id"].type_hint, "bigint")
self.assertTrue(fields["id"].primary_key)
self.assertFalse(fields["name"].optional)
self.assertTrue(fields["weight"].optional)
self.assertEqual(fields["born_on"].type_hint, "datetime")
self.assertEqual(fields["category"].foreign_key, "Category")
self.assertFalse(fields["category"].many)
self.assertEqual(fields["tags"].foreign_key, "Category")
self.assertTrue(fields["tags"].many)
def test_endpoints_carry_operation_shape(self):
by_id = {e.operation_id: e for e in self.extractor.endpoints()}
self.assertEqual(set(by_id), {"listPets", "createPet", "getPet"})
listing = by_id["listPets"]
self.assertEqual(listing.kind, "collection")
self.assertEqual(listing.response_model, "Pet")
self.assertTrue(listing.response_is_list)
# PetPage wraps the array, so a client expects {"items": [...]}.
self.assertEqual(listing.envelope_key, "items")
created = by_id["createPet"]
self.assertEqual(created.status, 201)
self.assertEqual(created.request_model, "Pet")
item = by_id["getPet"]
self.assertEqual(item.kind, "item")
self.assertEqual(item.path_params, ["petId"])
self.assertEqual(item.example, {"id": 7, "name": "Rocinante"})
def test_relations_reach_the_graphgen_schema(self):
out = self.dir / "schema.json"
GENERATORS["schema"]().generate(self.extractor.extract(), out)
schema = json.loads(out.read_text())["models"]
self.assertEqual(schema["Pet"]["fields"]["category"]["type"], "FK:Category")
self.assertEqual(schema["Pet"]["fields"]["tags"]["type"], "M2M:Category")
class TabularExtractorTest(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="modelgen-tabular-"))
self.sheets = self.dir / "sheets"
self.sheets.mkdir()
(self.sheets / "customers.csv").write_text(CUSTOMERS_CSV)
(self.sheets / "orders.csv").write_text(ORDERS_CSV)
write_ods(
self.sheets / "line_items.ods",
"line_items",
[
["line_id", "order_id", "sku", "qty", "note"],
["1", "1001", "A-1", "2", "rush"],
["2", "1001", "B-2", "1", ""],
["3", "1002", "A-1", "5", ""],
],
)
self.extractor = TabularExtractor(self.sheets)
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_detects_a_sheet_directory(self):
self.assertTrue(self.extractor.detect())
empty = self.dir / "empty"
empty.mkdir()
self.assertFalse(TabularExtractor(empty).detect())
def test_one_model_per_file_and_sheet(self):
models, _ = self.extractor.extract()
self.assertEqual(
{m.name for m in models}, {"Customers", "Orders", "LineItems"}
)
def test_types_are_inferred_per_column(self):
models, _ = self.extractor.extract()
fields = {
f.name: f for f in next(m for m in models if m.name == "Customers").fields
}
self.assertEqual(fields["id"].type_hint, int)
self.assertEqual(fields["name"].type_hint, str)
self.assertEqual(fields["active"].type_hint, bool)
self.assertEqual(fields["joined"].type_hint, "datetime")
self.assertEqual(fields["balance"].type_hint, float)
# One blank cell is what makes the column nullable.
self.assertTrue(fields["balance"].optional)
self.assertFalse(fields["name"].optional)
def test_keys_are_confirmed_against_the_data(self):
models, _ = self.extractor.extract()
by_name = {m.name: m for m in models}
customers = {f.name: f for f in by_name["Customers"].fields}
self.assertTrue(customers["id"].primary_key)
orders = {f.name: f for f in by_name["Orders"].fields}
self.assertTrue(orders["order_id"].primary_key)
# customer_id names another sheet, so it is a relation, not a key.
self.assertFalse(orders["customer_id"].primary_key)
self.assertEqual(orders["customer_id"].foreign_key, "Customers")
def test_ods_is_read_and_padding_ignored(self):
models, _ = self.extractor.extract()
line_items = next(m for m in models if m.name == "LineItems")
# Five real columns, not the 1023 the padding claims.
self.assertEqual([f.name for f in line_items.fields],
["line_id", "order_id", "sku", "qty", "note"])
fields = {f.name: f for f in line_items.fields}
self.assertEqual(fields["qty"].type_hint, int)
self.assertTrue(fields["note"].optional)
# line_id names the row rather than the sheet, but leads and holds.
self.assertTrue(fields["line_id"].primary_key)
self.assertEqual(fields["order_id"].foreign_key, "Orders")
def test_rows_are_kept_and_coerced(self):
self.extractor.extract()
datasets = {d.model: d for d in self.extractor.datasets()}
self.assertEqual(datasets["Customers"].collection, "customers")
rows = datasets["Customers"].rows
self.assertEqual(len(rows), 3)
self.assertEqual(rows[0]["id"], 1)
self.assertIs(rows[0]["active"], True)
self.assertEqual(rows[0]["balance"], 150.5)
# A blank cell is null, not the empty string.
self.assertIsNone(rows[2]["balance"])
self.assertEqual(datasets["LineItems"].rows[1]["note"], None)
class DatagenTargetTest(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="modelgen-datagen-"))
self.sheets = self.dir / "sheets"
self.sheets.mkdir()
(self.sheets / "customers.csv").write_text(CUSTOMERS_CSV)
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def _generate(self, seeded: bool):
extractor = TabularExtractor(self.sheets)
models, enums = extractor.extract()
datasets = extractor.datasets() if seeded else []
out = self.dir / "gen" / "datagen_demo.py"
DatagenGenerator(class_name="DemoGenerator").generate(
(models, enums, datasets), out
)
if seeded:
depot = out.parent / "depot"
depot.mkdir(exist_ok=True)
(depot / "data.json").write_text(
json.dumps({d.model: d.rows for d in datasets})
)
import importlib.util
spec = importlib.util.spec_from_file_location(f"demo_{seeded}", out)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.DemoGenerator()
def test_registered_as_a_target(self):
self.assertIn("datagen", GENERATORS)
def test_synthesises_when_there_are_no_rows(self):
generator = self._generate(seeded=False)
self.assertEqual(generator.available_models(), ["customers"])
record = generator.generate("Customers", 1)[0]
self.assertEqual(
set(record), {"id", "name", "email", "active", "joined", "balance"}
)
self.assertIsInstance(record["id"], int)
self.assertIsInstance(record["active"], bool)
self.assertIn("@", record["email"])
def test_samples_real_rows_when_seeded(self):
generator = self._generate(seeded=True)
names = {generator.generate("Customers", 1)[0]["name"] for _ in range(25)}
self.assertTrue(names <= {"Ada", "Bruno", "Camila"}, names)
def test_kwargs_override_the_result(self):
generator = self._generate(seeded=True)
self.assertEqual(generator.generate("Customers", 1, name="Zed")[0]["name"], "Zed")
def test_exposes_a_graphgen_schema(self):
schema = self._generate(seeded=False).schema()
self.assertIn("Customers", schema["models"])
self.assertTrue(schema["models"]["Customers"]["fields"]["id"]["pk"])
def test_unknown_model_is_reported(self):
generator = self._generate(seeded=False)
with self.assertRaises(ValueError):
generator.generate("Nope", 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -61,6 +61,11 @@ PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = {
"list": lambda base: f"List[{_get_list_inner(base)}]",
"enum": lambda base: base.__name__,
"dataclass": lambda base: base.__name__,
# DB- and spec-shaped hints. Without these an int64 column resolves through
# the "unknown -> str" fallback and a BigIntegerField arrives as a string.
"bigint": lambda _: "int",
"text": lambda _: "str",
"bytes": lambda _: "bytes",
}
# =============================================================================
@@ -95,6 +100,11 @@ TS_RESOLVERS: dict[Any, Callable[[Any], str]] = {
"list": _resolve_ts_list,
"enum": lambda base: base.__name__,
"dataclass": lambda base: base.__name__,
# JS has no 64-bit integer literal type, so int64 is a number like any
# other; text is a string; binary arrives base64-encoded over JSON.
"bigint": lambda _: "number",
"text": lambda _: "string",
"bytes": lambda _: "string",
}
# =============================================================================

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pawprint Wrapper - Demo</title>
<title>Sidebar Wrapper - Demo</title>
<link rel="stylesheet" href="sidebar.css">
<style>
/* Demo page styles */
@@ -19,7 +19,7 @@
transition: margin-right 0.3s ease;
}
#pawprint-sidebar.expanded ~ #demo-content {
#spr-sidebar.expanded ~ #demo-content {
margin-right: var(--sidebar-width);
}
@@ -105,14 +105,14 @@
<div id="demo-content">
<div class="demo-header">
<h1>🐾 Pawprint Wrapper</h1>
<p>Development tools sidebar for any pawprint-managed nest</p>
<h1>Sidebar Wrapper</h1>
<p>Development tools sidebar for any soleprint-managed room</p>
</div>
<div class="demo-section">
<h2>👋 Quick Start</h2>
<p>
This is a standalone demo of the Pawprint Wrapper sidebar.
This is a standalone demo of the sidebar wrapper.
Click the toggle button on the right edge of the screen, or press
<span class="kbd">Ctrl</span> + <span class="kbd">Shift</span> + <span class="kbd">P</span>
to open the sidebar.

View File

@@ -0,0 +1,127 @@
# shuntgen
Generates runnable [shunts](../../../artery/shunts/) from the two things people
actually have: a service contract, or a folder of spreadsheets.
```bash
# a spec you were handed
python -m station.tools.shuntgen from-openapi -s api.yaml -o artery/shunts/petstore
# sheets a client sent
python -m station.tools.shuntgen from-tabular -s ./sheets -o artery/shunts/books
python -m station.tools.shuntgen list
```
Run from `soleprint/`, so `station.tools...` resolves. Also available in the
browser at `/station/tools/shuntgen/`.
## What comes out
```
artery/shunts/<name>/
main.py builds the app from the spec
run.py uvicorn entry point (PORT, or depot/config.json)
shunt_runtime.py vendored copy of runtime.py
models.py pydantic, via modelgen
datagen_<name>.py BaseDataGenerator subclass, via modelgen
depot/spec.json routes, collections and schema
depot/responses.json pinned overrides — yours, never overwritten
depot/config.json delays, error rate, prefill — yours, never overwritten
depot/data.json imported rows
templates/index.html config UI
README.md
```
```bash
cd artery/shunts/books && python run.py
curl localhost:8098/customers
```
## Where a response comes from
First hit wins:
1. `depot/responses.json` — a pinned override, keyed `"METHOD /path"`
2. the store — rows imported from sheets, plus anything POSTed since
3. the spec's `example`, if the source document carried one
4. `datagen_<name>.py`, synthesising from the schema
5. `{}`
The store is the part that makes it behave like a service: POST something and
GET it back, ask for `/pets/7` and get the pet whose id is 7. Collections with
no imported rows are prefilled with generated ones (`prefill` in
`depot/config.json`) so the first call answers with something.
## Two sources, one pipeline
Both inputs are modelgen extractors, so the same shapes also generate pydantic,
TypeScript, prisma and a graphgen schema:
```bash
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t schema,datagen
```
| Source | Becomes | Routes |
| --- | --- | --- |
| OpenAPI 3.x / Swagger 2.0 | one model per schema, enums as real Enums, `$ref` as relations | the operations the document declares |
| `.csv` / `.tsv` / `.ods` | one model per file or sheet, types inferred per column | five CRUD routes per table |
Keys and relations are inferred and then checked against the data: an `id`
column that is not unique is not treated as a key, and `customer_id` is only a
foreign key if a `customers` sheet came with it.
ODS is read with `zipfile` + `ElementTree` — no odfpy, no pandas — which is what
lets modelgen stay dependency-free and publishable on its own.
## Control endpoints
Every generated shunt serves these:
| Endpoint | Purpose |
| --- | --- |
| `GET /health` | liveness |
| `GET /mock/spec` | the routes it was built from |
| `GET /mock/stats` | call counts and row counts |
| `POST /mock/reset` | restore imported rows, clear counters |
| `GET,POST /mock/config` | delays, error rate, `unknown_id`, page size |
| `GET,POST /mock/responses` | pin an override; set a key to `null` to drop it |
```bash
# make it slow and flaky, the way the real thing is
curl -X POST localhost:8098/mock/config \
-H 'content-type: application/json' \
-d '{"enable_random_delays": true, "error_rate": 0.2}'
# make one call answer something specific
curl -X POST localhost:8098/mock/responses \
-H 'content-type: application/json' \
-d '{"GET /customers/1": {"id": 1, "name": "PINNED"}}'
```
`unknown_id` decides what an unknown key does: `generate` (the default) invents
a record wearing the id that was asked for, `404` refuses it. Generate by
default because a client pointed at a fresh shunt should just work; flip it when
the error path is what you are testing.
## Dependency containers
`--cabinet postgres,redis` writes a `cabinet.json` declaring what the shunt
expects. `python build.py --cfg <room>` composes those services into the room's
`docker-compose.yml`; on a cluster they install as rig addons of the same name.
See [cabinets](../../cabinets/README.md).
## Regenerating
Everything is overwritten except `depot/responses.json` and `depot/config.json`.
Fixing a bug in `runtime.py` and regenerating fixes it in every shunt, which is
why the routes are built from `spec.json` at startup rather than written out as
source.
## Fixtures
`fixtures/petstore.yaml` and `fixtures/sheets/` exercise the shapes that are
easy to get wrong — an enum, a `$ref`, an array of `$ref`, a wrapped collection,
a path parameter, an ODS sheet with padding and a blank column, and a foreign
key across two files.

View File

@@ -0,0 +1,29 @@
"""
Shuntgen - Generate runnable shunts from a service contract or a pile of sheets.
A shunt is artery's fake connector: a stand-in service that answers like the
real one so tests can run without it. Writing one by hand means copying
artery/shunts/example/ and filling in responses.json by hand, which is fine for
three endpoints and untenable for eighty.
This tool takes the two things people actually have —
an OpenAPI/Swagger document the service already exists somewhere
a directory of CSV/ODS sheets the data exists, the service does not
— and emits a shunt that runs: typed models, a data generator, seeded routes,
and a config UI.
Both inputs reach modelgen's IR through its own extractors, so the shapes also
generate pydantic, TypeScript, prisma and a graphgen schema for free. This
package only adds the emitter.
python -m station.tools.shuntgen from-openapi -s api.yaml -o artery/shunts/petstore
python -m station.tools.shuntgen from-tabular -s ./sheets -o artery/shunts/books
"""
from .emit import ShuntEmitter
__version__ = "0.1.0"
__all__ = ["ShuntEmitter"]

View File

@@ -0,0 +1,239 @@
"""
Shuntgen CLI.
python -m station.tools.shuntgen from-openapi -s api.yaml -o artery/shunts/petstore
python -m station.tools.shuntgen from-tabular -s ./sheets -o artery/shunts/books
python -m station.tools.shuntgen list
Run from the soleprint/ directory so `station.tools...` resolves.
"""
import argparse
import sys
from pathlib import Path
from .emit import SPR_ROOT, ShuntEmitter
def _name_for(args, source: Path) -> str:
"""The shunt's name: given, else the output folder, else the source."""
if getattr(args, "name", None):
return args.name
output = Path(args.output)
if output.name and output.name not in (".", ".."):
return output.name
return source.stem
def _refuse_to_clobber(output: Path, force: bool) -> None:
"""Regenerating is fine; overwriting something that is not a shunt is not."""
if not output.exists() or force:
return
if not any(output.iterdir()):
return
if (output / "depot" / "spec.json").exists():
return
print(
f"Error: {output} already exists and was not generated by shuntgen.\n"
" Pick another path, or pass --force to write into it anyway.",
file=sys.stderr,
)
sys.exit(1)
def cmd_from_openapi(args):
"""Build a shunt from an OpenAPI 3.x / Swagger 2.0 document."""
from ..modelgen.loader.extract.openapi import OpenAPIExtractor
spec_path = Path(args.spec)
if not spec_path.exists():
print(f"Error: Spec not found: {spec_path}", file=sys.stderr)
sys.exit(1)
output = Path(args.output)
_refuse_to_clobber(output, args.force)
extractor = OpenAPIExtractor(spec_path)
print(f"Reading spec: {spec_path}")
try:
models, enums = extractor.extract()
endpoints = extractor.endpoints()
except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if not endpoints:
print(
"Error: the spec declares no operations, so there is nothing to serve.",
file=sys.stderr,
)
sys.exit(1)
name = _name_for(args, spec_path)
print(f"Found {len(models)} models, {len(endpoints)} endpoints")
written = ShuntEmitter(
name=name,
output=output,
models=models,
enums=enums,
endpoints=endpoints,
title=args.title,
source=spec_path.name,
kind="openapi",
port=args.port,
cabinets=_cabinets(args),
).emit()
_report(written, name, args.port)
def cmd_from_tabular(args):
"""Build a shunt from a directory of CSV/TSV/ODS sheets."""
from ..modelgen.loader.extract.tabular import TabularExtractor
source_path = Path(args.source)
if not source_path.exists():
print(f"Error: Source not found: {source_path}", file=sys.stderr)
sys.exit(1)
output = Path(args.output)
_refuse_to_clobber(output, args.force)
extractor = TabularExtractor(source_path)
print(f"Reading sheets: {source_path}")
try:
models, enums = extractor.extract()
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
datasets = extractor.datasets()
rows = sum(len(d.rows) for d in datasets)
name = _name_for(args, source_path)
print(f"Found {len(models)} models, {rows} rows")
written = ShuntEmitter(
name=name,
output=output,
models=models,
enums=enums,
datasets=datasets,
title=args.title,
source=source_path.name,
kind="tabular",
port=args.port,
cabinets=_cabinets(args),
).emit()
_report(written, name, args.port)
def cmd_list(args):
"""List the shunts that exist under artery/shunts/."""
shunts_dir = SPR_ROOT / "artery" / "shunts"
if not shunts_dir.exists():
print(f"No shunts directory at {shunts_dir}")
return
found = False
for path in sorted(shunts_dir.iterdir()):
if not path.is_dir() or path.name.startswith(("_", ".")):
continue
found = True
spec = path / "depot" / "spec.json"
if spec.exists():
import json
try:
data = json.loads(spec.read_text())
print(
f" {path.name:<20} generated "
f"{len(data.get('routes', []))} routes from {data.get('source', '?')}"
)
continue
except (OSError, ValueError):
pass
print(f" {path.name:<20} hand-written")
if not found:
print(" (none)")
def _cabinets(args) -> list:
if not getattr(args, "cabinet", None):
return []
return [c.strip() for c in args.cabinet.split(",") if c.strip()]
def _report(output: Path, name: str, port: int) -> None:
print(f"\nWrote {output}")
print("\nRun it:")
print(f" cd {output} && python run.py")
print(f" curl localhost:{port}/health")
print(f" open http://localhost:{port}/")
def _add_common(parser, port_default: int) -> None:
parser.add_argument(
"--output", "-o", type=str, required=True,
help="Where to write the shunt (e.g. artery/shunts/petstore)",
)
parser.add_argument(
"--name", "-n", type=str, default=None,
help="Shunt name (default: the output folder's name)",
)
parser.add_argument(
"--title", type=str, default=None,
help="Display title for the config UI (default: derived from the name)",
)
parser.add_argument(
"--port", "-p", type=int, default=port_default,
help=f"Default port for run.py (default: {port_default})",
)
parser.add_argument(
"--cabinet", type=str, default=None,
help="Comma-separated dependency containers to declare (e.g. postgres,redis)",
)
parser.add_argument(
"--force", action="store_true",
help="Write into a non-empty directory that shuntgen did not generate",
)
def main():
parser = argparse.ArgumentParser(
description="Shuntgen - generate runnable shunts",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
subparsers = parser.add_subparsers(dest="command", required=True)
openapi_parser = subparsers.add_parser(
"from-openapi", help="Generate a shunt from an OpenAPI / Swagger document"
)
openapi_parser.add_argument(
"--spec", "-s", type=str, required=True,
help="Path to the spec (.json, .yaml or .yml)",
)
_add_common(openapi_parser, 8099)
openapi_parser.set_defaults(func=cmd_from_openapi)
tabular_parser = subparsers.add_parser(
"from-tabular", help="Generate a shunt from a directory of CSV/TSV/ODS sheets"
)
tabular_parser.add_argument(
"--source", "-s", type=str, required=True,
help="Directory of sheets (or a single .csv/.tsv/.ods file)",
)
_add_common(tabular_parser, 8098)
tabular_parser.set_defaults(func=cmd_from_tabular)
list_parser = subparsers.add_parser("list", help="List shunts under artery/shunts/")
list_parser.set_defaults(func=cmd_list)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,354 @@
"""FastAPI router for shuntgen — generate shunts from a spec or a sheet folder.
Mounted by run.py under /station, so the routes below live at
/station/tools/shuntgen/... , the same shape as datagen and graphgen.
Generation writes files, so every path is resolved against the soleprint tree
and anything pointing outside it is refused. A tool that turns an uploaded file
into a directory of Python is not somewhere to be relaxed about paths.
"""
import json
import logging
import shutil
import tempfile
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from .emit import SPR_ROOT, ShuntEmitter
log = logging.getLogger(__name__)
router = APIRouter(prefix="/tools/shuntgen", tags=["shuntgen"])
SHUNTS_DIR = SPR_ROOT / "artery" / "shunts"
UPLOAD_DIR = SPR_ROOT / "station" / "tools" / "shuntgen" / "uploads"
# Room for a large spec, a hard stop well short of anything that would exhaust
# memory while being parsed.
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
# ─────────────────────────────────────────────────────
# Path safety
# ─────────────────────────────────────────────────────
def _inside(path: Path, root: Path) -> Path:
"""Resolve a path and refuse it if it escapes root."""
resolved = (root / path).resolve() if not path.is_absolute() else path.resolve()
try:
resolved.relative_to(root.resolve())
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Path must stay inside {root.name}/: {path}",
)
return resolved
def _safe_name(name: str) -> str:
cleaned = "".join(c for c in name if c.isalnum() or c in "-_").strip("-_")
if not cleaned:
raise HTTPException(status_code=400, detail="Name must contain letters or digits")
return cleaned.lower()
# ─────────────────────────────────────────────────────
# Models
# ─────────────────────────────────────────────────────
class GenerateRequest(BaseModel):
name: str
source: str # spec file or sheet directory, relative to soleprint/
kind: str = "auto" # "openapi" | "tabular" | "auto"
title: Optional[str] = None
port: int = 8099
cabinets: list[str] = []
force: bool = False
# ─────────────────────────────────────────────────────
# Routes
# ─────────────────────────────────────────────────────
@router.get("/", response_class=HTMLResponse)
def index():
html = Path(__file__).parent / "templates" / "index.html"
return HTMLResponse(html.read_text() if html.exists() else "<h1>shuntgen</h1>")
@router.get("/health")
def health():
return {"status": "ok", "tool": "shuntgen"}
@router.get("/api/shunts")
def list_shunts():
"""Every shunt under artery/shunts/, generated or hand-written."""
out = []
if SHUNTS_DIR.exists():
for path in sorted(SHUNTS_DIR.iterdir()):
if not path.is_dir() or path.name.startswith(("_", ".")):
continue
entry: dict[str, Any] = {"name": path.name, "generated": False}
spec_file = path / "depot" / "spec.json"
if spec_file.exists():
try:
spec = json.loads(spec_file.read_text())
entry.update(
generated=True,
title=spec.get("title"),
kind=spec.get("kind"),
source=spec.get("source"),
routes=len(spec.get("routes", [])),
models=len(spec.get("models", {})),
)
except (OSError, ValueError) as e:
entry["error"] = str(e)
out.append(entry)
return {"shunts": out}
@router.get("/api/sources")
def list_sources():
"""Candidate inputs: uploaded specs, and sheet folders under uploads/."""
specs, sheets = [], []
if UPLOAD_DIR.exists():
for path in sorted(UPLOAD_DIR.rglob("*")):
rel = str(path.relative_to(SPR_ROOT))
if path.is_file() and path.suffix.lower() in (".json", ".yaml", ".yml"):
specs.append(rel)
elif path.is_dir() and any(
child.suffix.lower() in (".csv", ".tsv", ".ods")
for child in path.iterdir()
if child.is_file()
):
sheets.append(rel)
fixtures = Path("station/tools/shuntgen/fixtures")
if (SPR_ROOT / fixtures).exists():
specs.append(str(fixtures / "petstore.yaml"))
sheets.append(str(fixtures / "sheets"))
return {"specs": specs, "sheets": sheets}
def _multipart_available() -> bool:
"""Whether FastAPI can accept file uploads in this environment.
Declaring an UploadFile parameter without python-multipart raises at import
time, not request time. Registering it unconditionally would mean one
missing optional dependency stops the whole tool from loading — so the
route is registered only when it can work, and a stub explains its absence.
"""
try:
import multipart # noqa: F401
return True
except ImportError:
return False
async def _upload(file: UploadFile, folder: str = ""):
"""Accept a spec or sheet, into uploads/[folder]/."""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename")
suffix = Path(file.filename).suffix.lower()
if suffix not in (".json", ".yaml", ".yml", ".csv", ".tsv", ".ods"):
raise HTTPException(
status_code=400,
detail=f"Unsupported file type '{suffix}'. "
"Expected a spec (.json/.yaml/.yml) or a sheet (.csv/.tsv/.ods).",
)
payload = await file.read()
if len(payload) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"File is larger than {MAX_UPLOAD_BYTES // (1024 * 1024)}MB",
)
target_dir = UPLOAD_DIR
if folder:
target_dir = _inside(Path("station/tools/shuntgen/uploads") / _safe_name(folder), SPR_ROOT)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / Path(file.filename).name
target.write_bytes(payload)
return {"path": str(target.relative_to(SPR_ROOT)), "bytes": len(payload)}
if _multipart_available():
router.add_api_route("/api/upload", _upload, methods=["POST"], name="upload")
else:
@router.post("/api/upload")
def upload_unavailable():
raise HTTPException(
status_code=501,
detail="Uploads need python-multipart. Install it, or point "
"'source' at a path on disk — generation itself works either way.",
)
@router.post("/api/generate")
def generate(req: GenerateRequest):
"""Generate a shunt into artery/shunts/<name>/."""
name = _safe_name(req.name)
source = _inside(Path(req.source), SPR_ROOT)
if not source.exists():
raise HTTPException(status_code=404, detail=f"Source not found: {req.source}")
output = _inside(Path("artery/shunts") / name, SPR_ROOT)
if (
output.exists()
and any(output.iterdir())
and not (output / "depot" / "spec.json").exists()
and not req.force
):
raise HTTPException(
status_code=409,
detail=f"artery/shunts/{name} exists and was not generated by shuntgen. "
"Pass force to write into it anyway.",
)
kind = req.kind
if kind == "auto":
kind = "tabular" if source.is_dir() else "openapi"
try:
if kind == "openapi":
from ..modelgen.loader.extract.openapi import OpenAPIExtractor
extractor = OpenAPIExtractor(source)
models, enums = extractor.extract()
endpoints = extractor.endpoints()
datasets = []
if not endpoints:
raise HTTPException(
status_code=400,
detail="The spec declares no operations, so there is nothing to serve.",
)
elif kind == "tabular":
from ..modelgen.loader.extract.tabular import TabularExtractor
extractor = TabularExtractor(source)
models, enums = extractor.extract()
datasets = extractor.datasets()
endpoints = []
else:
raise HTTPException(status_code=400, detail=f"Unknown kind: {req.kind}")
except HTTPException:
raise
except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
log.exception("shuntgen: extraction failed")
raise HTTPException(status_code=500, detail=str(e))
try:
written = ShuntEmitter(
name=name,
output=output,
models=models,
enums=enums,
datasets=datasets,
endpoints=endpoints,
title=req.title,
source=source.name,
kind=kind,
port=req.port,
cabinets=req.cabinets,
).emit()
except Exception as e:
log.exception("shuntgen: emission failed")
raise HTTPException(status_code=500, detail=str(e))
spec = json.loads((written / "depot" / "spec.json").read_text())
return {
"name": name,
"path": str(written.relative_to(SPR_ROOT)),
"kind": kind,
"models": len(models),
"routes": len(spec.get("routes", [])),
"rows": sum(len(d.rows) for d in datasets),
"run": f"cd {written.relative_to(SPR_ROOT)} && python run.py",
}
@router.post("/api/preview")
def preview(req: GenerateRequest):
"""Extract and report what would be generated, writing nothing permanent."""
source = _inside(Path(req.source), SPR_ROOT)
if not source.exists():
raise HTTPException(status_code=404, detail=f"Source not found: {req.source}")
kind = req.kind
if kind == "auto":
kind = "tabular" if source.is_dir() else "openapi"
try:
if kind == "openapi":
from ..modelgen.loader.extract.openapi import OpenAPIExtractor
extractor = OpenAPIExtractor(source)
models, enums = extractor.extract()
endpoints, datasets = extractor.endpoints(), []
else:
from ..modelgen.loader.extract.tabular import TabularExtractor
extractor = TabularExtractor(source)
models, enums = extractor.extract()
datasets, endpoints = extractor.datasets(), []
except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=400, detail=str(e))
# Emit into a throwaway directory: the routes are worked out by the emitter,
# and duplicating that logic here is how a preview drifts from the thing it
# is previewing.
temp = Path(tempfile.mkdtemp(prefix="shuntgen-preview-"))
try:
emitter = ShuntEmitter(
name=_safe_name(req.name or "preview"),
output=temp,
models=models,
enums=enums,
datasets=datasets,
endpoints=endpoints,
source=source.name,
kind=kind,
)
collections = emitter._collections()
routes = emitter._routes(collections)
finally:
shutil.rmtree(temp, ignore_errors=True)
return {
"kind": kind,
"models": [
{
"name": m.name,
"fields": len(m.fields),
"doc": (m.docstring or "").strip().splitlines()[0] if m.docstring else None,
}
for m in models
],
"collections": collections,
"routes": [
{
"method": r["method"],
"path": r["path"],
"operation": r["operation"],
"model": r["model"],
}
for r in routes
],
"rows": sum(len(d.rows) for d in datasets),
}

View File

@@ -0,0 +1,462 @@
"""
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))

View File

@@ -0,0 +1,147 @@
# A small OpenAPI 3 document used to exercise shuntgen and the OpenAPI loader.
#
# Deliberately covers the shapes that are easy to get wrong: an enum, a $ref to
# another schema, an array of $ref, a wrapped collection, a path parameter, a
# request body, and a response example.
openapi: 3.0.3
info:
title: Petstore
version: 1.0.0
paths:
/pets:
get:
operationId: listPets
summary: List all pets
responses:
"200":
description: A page of pets
content:
application/json:
schema:
$ref: "#/components/schemas/PetPage"
post:
operationId: createPet
summary: Create a pet
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
responses:
"201":
description: The created pet
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
/pets/{petId}:
get:
operationId: getPet
summary: Fetch one pet
parameters:
- name: petId
in: path
required: true
schema:
type: integer
responses:
"200":
description: The pet
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
example:
id: 7
name: "Rocinante"
status: "available"
"404":
description: No such pet
delete:
operationId: deletePet
summary: Remove a pet
parameters:
- name: petId
in: path
required: true
schema:
type: integer
responses:
"204":
description: Deleted
/categories:
get:
operationId: listCategories
summary: List categories
responses:
"200":
description: Every category
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Category"
components:
schemas:
Pet:
type: object
description: An animal available for adoption.
required: [id, name]
properties:
id:
type: integer
format: int64
name:
type: string
status:
type: string
enum: [available, pending, sold]
weight_kg:
type: number
neutered:
type: boolean
born_on:
type: string
format: date
category:
$ref: "#/components/schemas/Category"
tags:
type: array
items:
$ref: "#/components/schemas/Tag"
Category:
type: object
description: A grouping of pets.
required: [id, name]
properties:
id:
type: integer
name:
type: string
Tag:
type: object
required: [id]
properties:
id:
type: string
format: uuid
label:
type: string
PetPage:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/Pet"
total:
type: integer

View File

@@ -0,0 +1,6 @@
id,name,email,city,active,signed_up_at,credit_limit
1,Ada Alvarez,ada@example.com,Buenos Aires,true,2024-03-11,15000.50
2,Bruno Bianchi,bruno@example.com,Rosario,true,2024-05-02,8000
3,Camila Castro,camila@example.com,Cordoba,false,2023-11-27,
4,Diego Duarte,diego@example.com,Montevideo,true,2025-01-19,22500.75
5,Elena Esposito,elena@example.com,Santiago,false,2025-06-30,3000
1 id name email city active signed_up_at credit_limit
2 1 Ada Alvarez ada@example.com Buenos Aires true 2024-03-11 15000.50
3 2 Bruno Bianchi bruno@example.com Rosario true 2024-05-02 8000
4 3 Camila Castro camila@example.com Cordoba false 2023-11-27
5 4 Diego Duarte diego@example.com Montevideo true 2025-01-19 22500.75
6 5 Elena Esposito elena@example.com Santiago false 2025-06-30 3000

Some files were not shown because too many files have changed in this diff Show More