Compare commits
6 Commits
9fc4c23143
...
910927993e
| Author | SHA1 | Date | |
|---|---|---|---|
| 910927993e | |||
| 9a6337e493 | |||
| 0b04516cbb | |||
| ef63b02554 | |||
| 33f0559268 | |||
| dfb1991ae3 |
16
.gitignore
vendored
@@ -9,9 +9,25 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
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)
|
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
||||||
gen/
|
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)
|
# Room configurations (separate repo - contains credentials and room-specific data)
|
||||||
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
|
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
|
||||||
cfg/amar/
|
cfg/amar/
|
||||||
|
|||||||
147
CLAUDE.md
@@ -51,20 +51,19 @@ spr/
|
|||||||
│ └── amar/ # Amar room config
|
│ └── amar/ # Amar room config
|
||||||
│ ├── config.json
|
│ ├── config.json
|
||||||
│ ├── data/
|
│ ├── data/
|
||||||
│ ├── artery/ # Amar-specific (merged into output)
|
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
|
||||||
│ │ └── shunts/amar/
|
│ │ ├── artery/ # room shunts, pulses
|
||||||
│ ├── atlas/ # Amar-specific books
|
│ │ │ └── shunts/amar/
|
||||||
│ │ └── books/
|
│ │ ├── atlas/ # room books
|
||||||
│ ├── station/ # Amar-specific tools config
|
│ │ │ └── books/
|
||||||
│ │ └── tools/datagen/
|
│ │ ├── station/ # room tool configs
|
||||||
|
│ │ │ └── tools/datagen/
|
||||||
|
│ │ └── nginx/
|
||||||
|
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
|
||||||
│ ├── link/ # Bridge to managed app
|
│ ├── link/ # Bridge to managed app
|
||||||
│ ├── soleprint/ # Soleprint docker config
|
│ └── amar/ # The managed app itself
|
||||||
│ ├── databrowse/
|
|
||||||
│ ├── tester/
|
|
||||||
│ ├── monitors/
|
|
||||||
│ └── models/
|
|
||||||
│
|
│
|
||||||
├── ctrl/ # Build/run scripts
|
├── ctrl/ # Build/run scripts (see Build & Run)
|
||||||
│
|
│
|
||||||
└── gen/ # Built instances (gitignored)
|
└── gen/ # Built instances (gitignored)
|
||||||
├── standalone/
|
├── standalone/
|
||||||
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
|
|||||||
- `config.json` - Framework branding/terminology
|
- `config.json` - Framework branding/terminology
|
||||||
- `data/` - Data files (veins.json, shunts.json, etc.)
|
- `data/` - Data files (veins.json, shunts.json, etc.)
|
||||||
|
|
||||||
Room-specific system configs (merged into output):
|
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
|
||||||
- `artery/` - Room-specific shunts, pulses
|
the core `soleprint/` tree at build time:
|
||||||
- `atlas/` - Room-specific books
|
- `soleprint/artery/` - Room-specific shunts, pulses
|
||||||
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
|
- `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
|
## 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
|
```bash
|
||||||
# Build
|
make # = make help
|
||||||
python build.py # -> gen/standalone/
|
make build [room|all|models] # -> gen/<room>/ (default: standalone)
|
||||||
python build.py --cfg amar # -> gen/amar/
|
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
|
||||||
python build.py --all # -> all rooms
|
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
|
Every script stays runnable on its own — the standalone rule holds:
|
||||||
cd gen/standalone && python run.py
|
|
||||||
|
|
||||||
# Using ctrl scripts
|
```bash
|
||||||
./ctrl/build.sh [room]
|
python build.py --cfg amar # -> gen/amar/
|
||||||
./ctrl/start.sh [room] [-d]
|
cd gen/standalone && python run.py # bare-metal
|
||||||
./ctrl/stop.sh [room]
|
./ctrl/kind-up.sh # still works directly
|
||||||
|
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding a New Room
|
## Adding a New Room
|
||||||
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
|
|||||||
cp cfg/standalone/config.json cfg/newroom/
|
cp cfg/standalone/config.json cfg/newroom/
|
||||||
cp -r cfg/standalone/data/* cfg/newroom/data/
|
cp -r cfg/standalone/data/* cfg/newroom/data/
|
||||||
|
|
||||||
# Add room-specific configs as needed:
|
# Add room-specific configs as needed (note the soleprint/ overlay level):
|
||||||
# cfg/newroom/artery/shunts/...
|
# cfg/newroom/soleprint/artery/shunts/...
|
||||||
# cfg/newroom/atlas/books/...
|
# cfg/newroom/soleprint/atlas/books/...
|
||||||
# cfg/newroom/station/tools/...
|
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
|
||||||
|
|
||||||
python build.py --cfg newroom
|
make build newroom
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
@@ -149,10 +160,51 @@ python build.py --cfg newroom
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| modelgen | Generate models from config |
|
| modelgen | Generate models from config |
|
||||||
| datagen | Generate test data (uses faker) |
|
| datagen | Generate test data (uses faker) |
|
||||||
| tester | BDD/playwright test runner |
|
| tester | HTTP contract test runner |
|
||||||
| graphgen | Generate navigable model graphs |
|
| graphgen | Generate navigable model graphs |
|
||||||
| databrowse | SQL data browser |
|
| 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
|
## External Paths
|
||||||
|
|
||||||
| What | Path |
|
| What | Path |
|
||||||
@@ -161,6 +213,35 @@ python build.py --cfg newroom
|
|||||||
|
|
||||||
## Files Ignored
|
## 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
|
- `fails/`, `def/` - Drafts
|
||||||
- `__pycache__/`, `.venv/`
|
- `__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
@@ -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
@@ -317,6 +317,221 @@ def copy_cfg(output_dir: Path, room: str):
|
|||||||
copy_path(item, output_dir / item.name)
|
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):
|
def build_soleprint(output_dir: Path, room: str):
|
||||||
"""Build soleprint folder with core + room config merged."""
|
"""Build soleprint folder with core + room config merged."""
|
||||||
soleprint = SPR_ROOT / "soleprint"
|
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)
|
# Room config (includes merging room-specific artery/atlas/station)
|
||||||
copy_cfg(output_dir, room)
|
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
|
# Generate models
|
||||||
log.info("Generating models...")
|
log.info("Generating models...")
|
||||||
if not generate_models(output_dir, room):
|
if not generate_models(output_dir, room):
|
||||||
@@ -419,7 +639,11 @@ def main():
|
|||||||
elif args.all:
|
elif args.all:
|
||||||
build(SPR_ROOT / "gen" / "standalone", None)
|
build(SPR_ROOT / "gen" / "standalone", None)
|
||||||
for room in (SPR_ROOT / "cfg").iterdir():
|
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)
|
build(SPR_ROOT / "gen" / room.name, room.name)
|
||||||
else:
|
else:
|
||||||
if args.output:
|
if args.output:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Pawprint Data Layer
|
Soleprint Data Layer
|
||||||
|
|
||||||
JSON file storage (future: MongoDB)
|
JSON file storage (future: MongoDB)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Pawprint Data Layer
|
Soleprint Data Layer
|
||||||
|
|
||||||
JSON file storage (future: MongoDB)
|
JSON file storage (future: MongoDB)
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"items": [
|
"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
@@ -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
@@ -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
|
||||||
47
ctrl/spr.py
@@ -11,6 +11,7 @@ Usage:
|
|||||||
python ctrl/spr.py sync soleprint-ui ~/wdir/unt/ui/framework
|
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 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 ~/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
|
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"])
|
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):
|
def cmd_publish(args):
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
comp_type, source = resolve_component(registry, args.component)
|
comp_type, source = resolve_component(registry, args.component)
|
||||||
@@ -202,12 +234,17 @@ def cmd_publish(args):
|
|||||||
if dest.exists():
|
if dest.exists():
|
||||||
shutil.rmtree(dest)
|
shutil.rmtree(dest)
|
||||||
|
|
||||||
|
if args.dist:
|
||||||
|
count = publish_dist(source, dest)
|
||||||
|
mode = "published-dist"
|
||||||
|
else:
|
||||||
count = copy_tree(source, dest)
|
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)
|
version = get_version(comp_type, source)
|
||||||
sha = get_sha()
|
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):
|
def cmd_sync(args):
|
||||||
@@ -306,6 +343,12 @@ def main():
|
|||||||
p = sub.add_parser(cmd)
|
p = sub.add_parser(cmd)
|
||||||
p.add_argument("component", help="component name")
|
p.add_argument("component", help="component name")
|
||||||
p.add_argument("dest", help="target folder path")
|
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 = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
|
||||||
p.add_argument("component", help="component name")
|
p.add_argument("component", help="component name")
|
||||||
|
|||||||
35
ctrl/start.sh
Executable 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
@@ -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
@@ -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.
|
||||||
107
docs/data/en/station-cabinets.md
Normal 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.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# Datagen
|
# 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
|
**Status:** live
|
||||||
|
|
||||||
@@ -8,50 +10,95 @@ Test data generator using faker. Produces realistic, domain-specific data for te
|
|||||||
|
|
||||||
## What It Does
|
## 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
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
soleprint/station/tools/datagen/ # Core (base classes, placeholder)
|
soleprint/station/tools/datagen/ # base class, api, UI
|
||||||
cfg/<room>/soleprint/station/tools/datagen/ # Room-specific generators
|
cfg/<room>/soleprint/station/tools/datagen/ # the room's generators
|
||||||
```
|
```
|
||||||
|
|
||||||
After build, both merge into `gen/<room>/station/tools/datagen/`.
|
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
|
```python
|
||||||
from station.tools.datagen.base import BaseGenerator
|
from station.tools.datagen.base import BaseDataGenerator
|
||||||
|
|
||||||
class RoomDataGenerator(BaseGenerator):
|
class RoomDataGenerator(BaseDataGenerator):
|
||||||
def generate_customers(self, count=10):
|
def customer(self, **kwargs):
|
||||||
return [self.fake_customer() for _ in range(count)]
|
return {"id": str(uuid4()), "name": ..., "email": ..., **kwargs}
|
||||||
|
|
||||||
def fake_customer(self):
|
def invoice(self, customer_id=None, **kwargs):
|
||||||
return {
|
return {"id": str(uuid4()), "customer_id": customer_id, **kwargs}
|
||||||
"name": self.faker.name(),
|
```
|
||||||
"email": self.faker.email(),
|
|
||||||
"phone": self.faker.phone_number(),
|
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:
|
| Route | Returns |
|
||||||
- Base generator class with faker instance
|
| --- | --- |
|
||||||
- CLI entry point
|
| `GET /` | the browser UI |
|
||||||
- Output formatting (JSON, CSV)
|
| `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:
|
`FK:<Model>` and `M2M:<Model>` are how relations are written. modelgen's
|
||||||
- Domain-specific generator subclasses
|
`datagen` target emits this method for you.
|
||||||
- Field definitions and relationships
|
|
||||||
- Volume and distribution configuration
|
|
||||||
|
|||||||
@@ -1,54 +1,111 @@
|
|||||||
# Modelgen
|
# 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
|
## 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
|
dataclasses ─┐ ┌─ pydantic
|
||||||
- **Prisma** -- Prisma schema definitions
|
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
|
```bash
|
||||||
- **SQLAlchemy extractor** -- reads SQLAlchemy model files
|
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript,schema
|
||||||
- **Prisma extractor** -- reads Prisma schema files
|
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.
|
||||||
|
|
||||||
```
|
### From spreadsheets
|
||||||
gen/<room>/models/
|
|
||||||
├── pydantic/
|
|
||||||
├── django/
|
|
||||||
└── prisma/
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
The rows are kept, not just the shape — which is what lets the `datagen` target
|
||||||
python -m modelgen
|
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.
|
||||||
|
|||||||
129
docs/data/en/station-shuntgen.md
Normal 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.
|
||||||
@@ -22,8 +22,11 @@
|
|||||||
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true},
|
{"id": "station-datagen", "title": {"en": "↳ Datagen"}, "sub": true},
|
||||||
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true},
|
{"id": "station-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true},
|
||||||
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "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-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true},
|
||||||
|
{"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true},
|
||||||
|
|
||||||
{"id": "components", "title": {"en": "Shared Components"}},
|
{"id": "components", "title": {"en": "Shared Components"}},
|
||||||
|
{"id": "export", "title": {"en": "Export / Compile"}},
|
||||||
{"id": "deployment", "title": {"en": "Deployment"}}
|
{"id": "deployment", "title": {"en": "Deployment"}}
|
||||||
]
|
]
|
||||||
|
|||||||
67
docs/graphs/README.md
Normal 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.
|
||||||
@@ -1,43 +1,37 @@
|
|||||||
digraph artery_hierarchy {
|
digraph artery_hierarchy {
|
||||||
bgcolor="#0a0a0a"
|
|
||||||
rankdir=LR
|
rankdir=LR
|
||||||
fontname="Helvetica"
|
fontname="Helvetica"
|
||||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
|
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
|
||||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#b91c1c"]
|
edge [class="artery" fontname="Helvetica" fontsize=9]
|
||||||
|
|
||||||
label="Artery — Component Hierarchy"
|
label="Artery — Component Hierarchy"
|
||||||
labelloc=t
|
labelloc=t
|
||||||
fontsize=14
|
fontsize=14
|
||||||
fontcolor="#fca5a5"
|
|
||||||
|
|
||||||
vein [label="Vein\nstateless API connector" fillcolor="#1a1a1a"]
|
vein [label="Vein\nstateless API connector"]
|
||||||
pulse [label="Pulse\nVein + Room + Depot" fillcolor="#1a1a1a"]
|
pulse [label="Pulse\nVein + Room + Depot"]
|
||||||
plexus [label="Plexus\nfull app: backend\n+ frontend + DB" fillcolor="#1a1a1a"]
|
plexus [label="Plexus\nfull app: backend\n+ frontend + DB"]
|
||||||
shunt [label="Shunt\nfake connector\nfor testing" fillcolor="#1a1a1a" color="#d4a574"]
|
shunt [class="accent" label="Shunt\nfake connector\nfor testing"]
|
||||||
|
|
||||||
vein -> pulse [label="compose"]
|
vein -> pulse [label="compose"]
|
||||||
pulse -> plexus [label="extend"]
|
pulse -> plexus [label="extend"]
|
||||||
shunt -> vein [label="replaces" style=dashed color="#d4a574" fontcolor="#d4a574"]
|
shunt -> vein [class="accent" label="replaces" style=dashed]
|
||||||
|
|
||||||
// Examples
|
// Examples
|
||||||
subgraph cluster_examples {
|
subgraph cluster_examples {
|
||||||
label="Live Veins"
|
label="Live Veins"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#333"
|
|
||||||
fontcolor="#666"
|
|
||||||
|
|
||||||
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9]
|
jira [class="ok" label="Jira" fontsize=9]
|
||||||
google [label="Google" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9]
|
google [class="accent-text" label="Google" fontsize=9]
|
||||||
ia [label="IA" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9]
|
ia [class="ok" label="IA" fontsize=9]
|
||||||
}
|
}
|
||||||
|
|
||||||
subgraph cluster_shunts {
|
subgraph cluster_shunts {
|
||||||
label="Shunts"
|
label="Shunts"
|
||||||
style=dashed
|
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]
|
jira -> vein [style=invis]
|
||||||
|
|||||||
101
docs/graphs/artery_hierarchy.lucid.svg
Normal 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->vein -->
|
||||||
|
<!-- google -->
|
||||||
|
<g id="node2" class="node accent-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-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->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->pulse -->
|
||||||
|
<g id="edge1" class="edge artery">
|
||||||
|
<title>vein->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->plexus -->
|
||||||
|
<g id="edge2" class="edge artery">
|
||||||
|
<title>pulse->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->vein -->
|
||||||
|
<g id="edge3" class="edge accent">
|
||||||
|
<title>shunt->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 |
@@ -4,98 +4,98 @@
|
|||||||
<!-- Generated by graphviz version 14.1.2 (0)
|
<!-- Generated by graphviz version 14.1.2 (0)
|
||||||
-->
|
-->
|
||||||
<!-- Title: artery_hierarchy Pages: 1 -->
|
<!-- Title: artery_hierarchy Pages: 1 -->
|
||||||
<svg width="845pt" height="317pt"
|
<svg width="845pt" height="294pt"
|
||||||
viewBox="0.00 0.00 845.00 317.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
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 313.25)">
|
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 290.25)">
|
||||||
<title>artery_hierarchy</title>
|
<title>artery_hierarchy</title>
|
||||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-313.25 840.5,-313.25 840.5,4 -4,4"/>
|
<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="-291.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Component Hierarchy</text>
|
<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">
|
<g id="clust1" class="cluster">
|
||||||
<title>cluster_examples</title>
|
<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"/>
|
<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="-175.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text>
|
<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>
|
||||||
<g id="clust2" class="cluster">
|
<g id="clust2" class="cluster">
|
||||||
<title>cluster_shunts</title>
|
<title>cluster_shunts</title>
|
||||||
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-199 8,-276 100,-276 100,-199 8,-199"/>
|
<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="-258.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text>
|
<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>
|
</g>
|
||||||
<!-- vein -->
|
<!-- vein -->
|
||||||
<g id="node1" class="node">
|
<g id="node5" class="node">
|
||||||
<title>vein</title>
|
<title>vein</title>
|
||||||
<polygon fill="#1a1a1a" stroke="#333333" points="446,-167 300.25,-167 300.25,-131 446,-131 446,-167"/>
|
<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="-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="-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="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</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->vein -->
|
||||||
|
<!-- google -->
|
||||||
|
<g id="node2" class="node accent-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-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>
|
</g>
|
||||||
|
<!-- mp->shunt -->
|
||||||
<!-- pulse -->
|
<!-- pulse -->
|
||||||
<g id="node2" class="node">
|
<g id="node6" class="node">
|
||||||
<title>pulse</title>
|
<title>pulse</title>
|
||||||
<polygon fill="#1a1a1a" stroke="#333333" points="659.25,-167 522.5,-167 522.5,-131 659.25,-131 659.25,-167"/>
|
<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="-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="-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="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</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>
|
</g>
|
||||||
<!-- vein->pulse -->
|
<!-- vein->pulse -->
|
||||||
<g id="edge1" class="edge">
|
<g id="edge1" class="edge artery">
|
||||||
<title>vein->pulse</title>
|
<title>vein->pulse</title>
|
||||||
<path fill="none" stroke="#b91c1c" d="M446.27,-149C467.03,-149 489.79,-149 510.95,-149"/>
|
<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,-152.5 520.67,-149 510.67,-145.5 510.67,-152.5"/>
|
<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="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">compose</text>
|
<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>
|
</g>
|
||||||
<!-- plexus -->
|
<!-- plexus -->
|
||||||
<g id="node3" class="node">
|
<g id="node7" class="node">
|
||||||
<title>plexus</title>
|
<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"/>
|
<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="-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="-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="-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="-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="-131.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</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>
|
</g>
|
||||||
<!-- pulse->plexus -->
|
<!-- pulse->plexus -->
|
||||||
<g id="edge2" class="edge">
|
<g id="edge2" class="edge artery">
|
||||||
<title>pulse->plexus</title>
|
<title>pulse->plexus</title>
|
||||||
<path fill="none" stroke="#b91c1c" d="M659.48,-149C677.62,-149 697.19,-149 715.21,-149"/>
|
<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,-152.5 724.98,-149 714.98,-145.5 714.98,-152.5"/>
|
<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="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">extend</text>
|
<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 -->
|
|
||||||
<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>
|
|
||||||
</g>
|
</g>
|
||||||
<!-- shunt->vein -->
|
<!-- shunt->vein -->
|
||||||
<g id="edge3" class="edge">
|
<g id="edge3" class="edge accent">
|
||||||
<title>shunt->vein</title>
|
<title>shunt->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"/>
|
<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="315.71,-174.95 323.73,-168.03 313.14,-168.43 315.71,-174.95"/>
|
<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="-200.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text>
|
<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>
|
|
||||||
<!-- 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->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>
|
|
||||||
</g>
|
</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->shunt -->
|
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -1,43 +1,37 @@
|
|||||||
digraph cfg_gen_flow {
|
digraph cfg_gen_flow {
|
||||||
bgcolor="#0a0a0a"
|
|
||||||
rankdir=LR
|
rankdir=LR
|
||||||
fontname="Helvetica"
|
fontname="Helvetica"
|
||||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
|
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
|
||||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#d4a574"]
|
edge [class="accent" fontname="Helvetica" fontsize=9]
|
||||||
|
|
||||||
label="Build Flow — cfg/ to gen/"
|
label="Build Flow — cfg/ to gen/"
|
||||||
labelloc=t
|
labelloc=t
|
||||||
fontsize=14
|
fontsize=14
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
// Source
|
// Source
|
||||||
subgraph cluster_source {
|
subgraph cluster_source {
|
||||||
label="Source (committed)"
|
label="Source (committed)"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#333"
|
|
||||||
fontcolor="#666"
|
|
||||||
|
|
||||||
core [label="soleprint/\ncore framework" fillcolor="#1a1a1a"]
|
core [label="soleprint/\ncore framework"]
|
||||||
cfg [label="cfg/<room>/\nroom config" fillcolor="#1a1a1a"]
|
cfg [label="cfg/<room>/\nroom config"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build
|
// 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
|
// Output
|
||||||
subgraph cluster_output {
|
subgraph cluster_output {
|
||||||
label="Output (generated, gitignored)"
|
label="Output (generated, gitignored)"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#333"
|
|
||||||
fontcolor="#666"
|
|
||||||
|
|
||||||
gen_spr [label="gen/<room>/soleprint/\ncore + room merged" fillcolor="#1a1a1a"]
|
gen_spr [label="gen/<room>/soleprint/\ncore + room merged"]
|
||||||
gen_app [label="gen/<room>/<app>/\ncloned repos" fillcolor="#1a1a1a"]
|
gen_app [label="gen/<room>/<app>/\ncloned repos"]
|
||||||
gen_link [label="gen/<room>/link/\nDB bridge" fillcolor="#1a1a1a"]
|
gen_link [label="gen/<room>/link/\nDB bridge"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run
|
// Run
|
||||||
docker [label="docker compose up" fillcolor="#1a1a1a" shape=component]
|
docker [label="docker compose up" shape=component]
|
||||||
|
|
||||||
// Flow
|
// Flow
|
||||||
core -> build
|
core -> build
|
||||||
|
|||||||
114
docs/graphs/cfg_gen_flow.lucid.svg
Normal 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">--cfg <room></text>
|
||||||
|
</g>
|
||||||
|
<!-- core->build -->
|
||||||
|
<g id="edge1" class="edge accent">
|
||||||
|
<title>core->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/<room>/</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->build -->
|
||||||
|
<g id="edge2" class="edge accent">
|
||||||
|
<title>cfg->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/<room>/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->docker -->
|
||||||
|
<g id="edge6" class="edge accent">
|
||||||
|
<title>gen_spr->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/<room>/<app>/</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/<room>/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->gen_spr -->
|
||||||
|
<g id="edge3" class="edge accent">
|
||||||
|
<title>build->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->gen_app -->
|
||||||
|
<g id="edge4" class="edge accent">
|
||||||
|
<title>build->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->gen_link -->
|
||||||
|
<g id="edge5" class="edge accent">
|
||||||
|
<title>build->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 |
@@ -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>
|
<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>
|
</g>
|
||||||
<!-- build -->
|
<!-- build -->
|
||||||
<g id="node3" class="node">
|
<g id="node6" class="node accent">
|
||||||
<title>build</title>
|
<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"/>
|
<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"/>
|
<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">--cfg <room></text>
|
<text xml:space="preserve" text-anchor="middle" x="226.62" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">--cfg <room></text>
|
||||||
</g>
|
</g>
|
||||||
<!-- core->build -->
|
<!-- core->build -->
|
||||||
<g id="edge1" class="edge">
|
<g id="edge1" class="edge accent">
|
||||||
<title>core->build</title>
|
<title>core->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"/>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- cfg->build -->
|
<!-- cfg->build -->
|
||||||
<g id="edge2" class="edge">
|
<g id="edge2" class="edge accent">
|
||||||
<title>cfg->build</title>
|
<title>cfg->build</title>
|
||||||
<path fill="none" stroke="#d4a574" d="M119.41,-88C135.12,-88 153.15,-88 169.84,-88"/>
|
<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"/>
|
<polygon fill="#d4a574" stroke="#d4a574" points="169.63,-91.5 179.63,-88 169.63,-84.5 169.63,-91.5"/>
|
||||||
</g>
|
</g>
|
||||||
<!-- gen_spr -->
|
<!-- gen_spr -->
|
||||||
<g id="node4" class="node">
|
<g id="node3" class="node">
|
||||||
<title>gen_spr</title>
|
<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"/>
|
<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/<room>/soleprint/</text>
|
<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/<room>/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>
|
<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>
|
</g>
|
||||||
<!-- build->gen_spr -->
|
<!-- docker -->
|
||||||
<g id="edge3" class="edge">
|
<g id="node7" class="node">
|
||||||
<title>build->gen_spr</title>
|
<title>docker</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="#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"/>
|
||||||
<polygon fill="#d4a574" stroke="#d4a574" points="382.29,-127.55 392.81,-126.29 383.8,-120.71 382.29,-127.55"/>
|
<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->docker -->
|
||||||
|
<g id="edge6" class="edge accent">
|
||||||
|
<title>gen_spr->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>
|
</g>
|
||||||
<!-- gen_app -->
|
<!-- gen_app -->
|
||||||
<g id="node5" class="node">
|
<g id="node4" class="node">
|
||||||
<title>gen_app</title>
|
<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"/>
|
<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/<room>/<app>/</text>
|
<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/<room>/<app>/</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>
|
<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>
|
</g>
|
||||||
<!-- build->gen_app -->
|
|
||||||
<g id="edge4" class="edge">
|
|
||||||
<title>build->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 -->
|
<!-- gen_link -->
|
||||||
<g id="node6" class="node">
|
<g id="node5" class="node">
|
||||||
<title>gen_link</title>
|
<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"/>
|
<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/<room>/link/</text>
|
<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/<room>/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>
|
<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>
|
</g>
|
||||||
|
<!-- build->gen_spr -->
|
||||||
|
<g id="edge3" class="edge accent">
|
||||||
|
<title>build->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->gen_app -->
|
||||||
|
<g id="edge4" class="edge accent">
|
||||||
|
<title>build->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->gen_link -->
|
<!-- build->gen_link -->
|
||||||
<g id="edge5" class="edge">
|
<g id="edge5" class="edge accent">
|
||||||
<title>build->gen_link</title>
|
<title>build->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"/>
|
<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"/>
|
<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>
|
<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>
|
|
||||||
<!-- 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->docker -->
|
|
||||||
<g id="edge6" class="edge">
|
|
||||||
<title>gen_spr->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>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
69
docs/graphs/render.sh
Executable 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)"
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
digraph room_layers {
|
digraph room_layers {
|
||||||
bgcolor="#0a0a0a"
|
|
||||||
rankdir=TB
|
rankdir=TB
|
||||||
fontname="Helvetica"
|
fontname="Helvetica"
|
||||||
node [fontname="Helvetica" fontsize=10 style=filled color="#333" fontcolor="#e5e5e5" shape=record]
|
node [fontname="Helvetica" fontsize=10 style=filled shape=record]
|
||||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
|
edge [fontname="Helvetica" fontsize=9]
|
||||||
|
|
||||||
label="Room Layers — init wizard"
|
label="Room Layers — init wizard"
|
||||||
labelloc=t
|
labelloc=t
|
||||||
fontsize=14
|
fontsize=14
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
l0 [label="{Layer 0 | Config + Data | config.json · data/*.json}" fillcolor="#1a1a1a" color="#d4a574"]
|
l0 [class="accent" label="{Layer 0 | Config + Data | config.json · data/*.json}"]
|
||||||
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}" fillcolor="#1a1a1a"]
|
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}"]
|
||||||
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}" fillcolor="#1a1a1a"]
|
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}"]
|
||||||
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}" fillcolor="#1a1a1a"]
|
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}"]
|
||||||
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}" fillcolor="#1a1a1a"]
|
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}"]
|
||||||
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}" fillcolor="#1a1a1a"]
|
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}"]
|
||||||
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}" fillcolor="#1a1a1a"]
|
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}"]
|
||||||
|
|
||||||
l0 -> l1 [label="required"]
|
l0 -> l1 [label="required"]
|
||||||
l1 -> l2 [label="if managed"]
|
l1 -> l2 [label="if managed"]
|
||||||
@@ -26,6 +24,6 @@ digraph room_layers {
|
|||||||
l5 -> l6 [label="if frontend"]
|
l5 -> l6 [label="if frontend"]
|
||||||
|
|
||||||
// Annotations
|
// 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]
|
note_req -> l0 [style=invis]
|
||||||
}
|
}
|
||||||
|
|||||||
133
docs/graphs/room_layers.lucid.svg
Normal 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-compose.yml · .env</text>
|
||||||
|
</g>
|
||||||
|
<!-- l0->l1 -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>l0->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-compose.yml · Dockerfiles · .env</text>
|
||||||
|
</g>
|
||||||
|
<!-- l1->l2 -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>l1->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->l4 -->
|
||||||
|
<g id="edge3" class="edge">
|
||||||
|
<title>l1->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->l3 -->
|
||||||
|
<g id="edge4" class="edge">
|
||||||
|
<title>l2->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->l5 -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>l4->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-compose.nginx.yml</text>
|
||||||
|
</g>
|
||||||
|
<!-- l5->l6 -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>l5->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->l0 -->
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -11,7 +11,7 @@
|
|||||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-607.5 456.62,-607.5 456.62,4 -4,4"/>
|
<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>
|
<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 -->
|
<!-- l0 -->
|
||||||
<g id="node1" class="node">
|
<g id="node1" class="node accent">
|
||||||
<title>l0</title>
|
<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"/>
|
<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>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- l4 -->
|
<!-- l4 -->
|
||||||
<g id="node5" class="node">
|
<g id="node4" class="node">
|
||||||
<title>l4</title>
|
<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"/>
|
<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>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- l1->l4 -->
|
<!-- l1->l4 -->
|
||||||
<g id="edge4" class="edge">
|
<g id="edge3" class="edge">
|
||||||
<title>l1->l4</title>
|
<title>l1->l4</title>
|
||||||
<path fill="none" stroke="#666666" d="M252.84,-331.61C266.06,-319.14 281.48,-304.59 295.41,-291.45"/>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- l3 -->
|
<!-- l3 -->
|
||||||
<g id="node4" class="node">
|
<g id="node5" class="node">
|
||||||
<title>l3</title>
|
<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"/>
|
<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>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- l2->l3 -->
|
<!-- l2->l3 -->
|
||||||
<g id="edge3" class="edge">
|
<g id="edge4" class="edge">
|
||||||
<title>l2->l3</title>
|
<title>l2->l3</title>
|
||||||
<path fill="none" stroke="#666666" d="M105.5,-221.11C105.5,-209.81 105.5,-196.79 105.5,-184.67"/>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- note_req -->
|
<!-- note_req -->
|
||||||
<g id="node8" class="node">
|
<g id="node8" class="node accent">
|
||||||
<title>note_req</title>
|
<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"/>
|
<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="#d4a574">every room</text>
|
<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>
|
</g>
|
||||||
<!-- note_req->l0 -->
|
<!-- note_req->l0 -->
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -1,88 +1,78 @@
|
|||||||
digraph system_overview {
|
digraph system_overview {
|
||||||
bgcolor="#0a0a0a"
|
|
||||||
rankdir=TB
|
rankdir=TB
|
||||||
compound=true
|
compound=true
|
||||||
fontname="Helvetica"
|
fontname="Helvetica"
|
||||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5"]
|
node [fontname="Helvetica" fontsize=11 style=filled]
|
||||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
|
edge [fontname="Helvetica" fontsize=9]
|
||||||
|
|
||||||
label="Soleprint — System Overview"
|
label="Soleprint — System Overview"
|
||||||
labelloc=t
|
labelloc=t
|
||||||
fontsize=14
|
fontsize=14
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
// Core
|
// Core
|
||||||
subgraph cluster_core {
|
subgraph cluster_core {
|
||||||
label="Soleprint Hub"
|
label="Soleprint Hub"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#d4a574"
|
class="accent"
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
hub [label="soleprint\ncore coordinator\nport 12000" fillcolor="#1a1a1a" shape=box]
|
hub [label="soleprint\ncore coordinator\nport 12000" shape=box]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artery
|
// Artery
|
||||||
subgraph cluster_artery {
|
subgraph cluster_artery {
|
||||||
label="Artery — Todo lo vital"
|
label="Artery — Todo lo vital"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#b91c1c"
|
class="artery"
|
||||||
fontcolor="#fca5a5"
|
|
||||||
|
|
||||||
veins [label="Veins\nstateless connectors" fillcolor="#1a1a1a"]
|
veins [label="Veins\nstateless connectors"]
|
||||||
shunts [label="Shunts\nmock connectors" fillcolor="#1a1a1a"]
|
shunts [label="Shunts\nmock connectors"]
|
||||||
pulses [label="Pulses\ncomposed flows" fillcolor="#1a1a1a"]
|
pulses [label="Pulses\ncomposed flows"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atlas
|
// Atlas
|
||||||
subgraph cluster_atlas {
|
subgraph cluster_atlas {
|
||||||
label="Atlas — Documentacion accionable"
|
label="Atlas — Documentacion accionable"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#15803d"
|
class="atlas"
|
||||||
fontcolor="#86efac"
|
|
||||||
|
|
||||||
books [label="Books\ndocumentation" fillcolor="#1a1a1a"]
|
books [label="Books\ndocumentation"]
|
||||||
templates [label="Templates\npatterns" fillcolor="#1a1a1a"]
|
templates [label="Templates\npatterns"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station
|
// Station
|
||||||
subgraph cluster_station {
|
subgraph cluster_station {
|
||||||
label="Station — Centro de control"
|
label="Station — Centro de control"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#1d4ed8"
|
class="station"
|
||||||
fontcolor="#93c5fd"
|
|
||||||
|
|
||||||
tools [label="Tools\ntester · datagen · modelgen" fillcolor="#1a1a1a"]
|
tools [label="Tools\ntester · datagen · modelgen"]
|
||||||
monitors [label="Monitors\ndatabrowse" fillcolor="#1a1a1a"]
|
monitors [label="Monitors\ndatabrowse"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// External
|
// External
|
||||||
subgraph cluster_external {
|
subgraph cluster_external {
|
||||||
label="External APIs"
|
label="External APIs"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#333"
|
|
||||||
fontcolor="#666"
|
|
||||||
|
|
||||||
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
jira [label="Jira"]
|
||||||
google [label="Google" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
google [label="Google"]
|
||||||
slack [label="Slack" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
slack [label="Slack"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Managed app
|
// Managed app
|
||||||
subgraph cluster_managed {
|
subgraph cluster_managed {
|
||||||
label="Managed App"
|
label="Managed App"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#333"
|
|
||||||
fontcolor="#666"
|
|
||||||
|
|
||||||
app_fe [label="Frontend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
app_fe [label="Frontend"]
|
||||||
app_be [label="Backend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
app_be [label="Backend"]
|
||||||
app_db [label="Database" fillcolor="#1a1a1a" fontcolor="#a3a3a3" shape=cylinder]
|
app_db [label="Database" shape=cylinder]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connections
|
// Connections
|
||||||
hub -> veins [label="routes" color="#b91c1c"]
|
hub -> veins [class="artery" label="routes"]
|
||||||
hub -> books [label="routes" color="#15803d"]
|
hub -> books [class="atlas" label="routes"]
|
||||||
hub -> tools [label="routes" color="#1d4ed8"]
|
hub -> tools [class="station" label="routes"]
|
||||||
|
|
||||||
veins -> jira [label="API"]
|
veins -> jira [label="API"]
|
||||||
veins -> google [label="OAuth"]
|
veins -> google [label="OAuth"]
|
||||||
@@ -93,5 +83,5 @@ digraph system_overview {
|
|||||||
monitors -> app_db [label="browse" style=dashed]
|
monitors -> app_db [label="browse" style=dashed]
|
||||||
|
|
||||||
// Sidebar injection
|
// Sidebar injection
|
||||||
hub -> app_fe [label="sidebar\ninjection" color="#d4a574" style=dashed]
|
hub -> app_fe [class="accent" label="sidebar\ninjection" style=dashed]
|
||||||
}
|
}
|
||||||
|
|||||||
209
docs/graphs/system_overview.lucid.svg
Normal 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->veins -->
|
||||||
|
<g id="edge1" class="edge artery">
|
||||||
|
<title>hub->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->books -->
|
||||||
|
<g id="edge2" class="edge atlas">
|
||||||
|
<title>hub->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->tools -->
|
||||||
|
<g id="edge3" class="edge station">
|
||||||
|
<title>hub->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->app_fe -->
|
||||||
|
<g id="edge4" class="edge accent">
|
||||||
|
<title>hub->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->pulses -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>veins->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->jira -->
|
||||||
|
<g id="edge6" class="edge">
|
||||||
|
<title>veins->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->google -->
|
||||||
|
<g id="edge7" class="edge">
|
||||||
|
<title>veins->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->slack -->
|
||||||
|
<g id="edge8" class="edge">
|
||||||
|
<title>veins->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->app_be -->
|
||||||
|
<g id="edge9" class="edge">
|
||||||
|
<title>tools->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->app_db -->
|
||||||
|
<g id="edge10" class="edge">
|
||||||
|
<title>monitors->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 |
@@ -10,22 +10,22 @@
|
|||||||
<title>system_overview</title>
|
<title>system_overview</title>
|
||||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-368.25 1149,-368.25 1149,4 -4,4"/>
|
<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>
|
<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>
|
<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"/>
|
<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>
|
<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>
|
||||||
<g id="clust2" class="cluster">
|
<g id="clust2" class="cluster artery">
|
||||||
<title>cluster_artery</title>
|
<title>cluster_artery</title>
|
||||||
<polygon fill="#0a0a0a" stroke="#b91c1c" stroke-dasharray="5,2" points="8,-8 8,-212 382,-212 382,-8 8,-8"/>
|
<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>
|
<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>
|
||||||
<g id="clust3" class="cluster">
|
<g id="clust3" class="cluster atlas">
|
||||||
<title>cluster_atlas</title>
|
<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"/>
|
<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>
|
<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>
|
||||||
<g id="clust4" class="cluster">
|
<g id="clust4" class="cluster station">
|
||||||
<title>cluster_station</title>
|
<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"/>
|
<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>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- hub->veins -->
|
<!-- hub->veins -->
|
||||||
<g id="edge1" class="edge">
|
<g id="edge1" class="edge artery">
|
||||||
<title>hub->veins</title>
|
<title>hub->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"/>
|
<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"/>
|
<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>
|
</g>
|
||||||
<!-- books -->
|
<!-- books -->
|
||||||
<g id="node5" class="node">
|
<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>
|
<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>
|
</g>
|
||||||
<!-- hub->books -->
|
<!-- hub->books -->
|
||||||
<g id="edge2" class="edge">
|
<g id="edge2" class="edge atlas">
|
||||||
<title>hub->books</title>
|
<title>hub->books</title>
|
||||||
<path fill="none" stroke="#15803d" d="M572.81,-249C552.24,-230.55 523.47,-204.75 501.27,-184.84"/>
|
<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"/>
|
<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>
|
</g>
|
||||||
<!-- tools -->
|
<!-- tools -->
|
||||||
<g id="node7" class="node">
|
<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>
|
<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>
|
</g>
|
||||||
<!-- hub->tools -->
|
<!-- hub->tools -->
|
||||||
<g id="edge3" class="edge">
|
<g id="edge3" class="edge station">
|
||||||
<title>hub->tools</title>
|
<title>hub->tools</title>
|
||||||
<path fill="none" stroke="#1d4ed8" d="M639.59,-249C672.13,-230.17 717.93,-203.66 752.6,-183.59"/>
|
<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"/>
|
<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>
|
</g>
|
||||||
<!-- app_fe -->
|
<!-- app_fe -->
|
||||||
<g id="node12" class="node">
|
<g id="node12" class="node">
|
||||||
<title>app_fe</title>
|
<title>app_fe</title>
|
||||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="1089" cy="-40.75" rx="39.9" ry="18"/>
|
<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>
|
</g>
|
||||||
<!-- hub->app_fe -->
|
<!-- hub->app_fe -->
|
||||||
<g id="edge10" class="edge">
|
<g id="edge4" class="edge accent">
|
||||||
<title>hub->app_fe</title>
|
<title>hub->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"/>
|
<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"/>
|
<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="-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="#a3a3a3">injection</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>
|
</g>
|
||||||
<!-- pulses -->
|
<!-- pulses -->
|
||||||
<g id="node4" class="node">
|
<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>
|
<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>
|
</g>
|
||||||
<!-- veins->pulses -->
|
<!-- veins->pulses -->
|
||||||
<g id="edge7" class="edge">
|
<g id="edge5" class="edge">
|
||||||
<title>veins->pulses</title>
|
<title>veins->pulses</title>
|
||||||
<path fill="none" stroke="#666666" d="M108,-128.86C108,-113.7 108,-93.88 108,-76.98"/>
|
<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"/>
|
<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">
|
<g id="node9" class="node">
|
||||||
<title>jira</title>
|
<title>jira</title>
|
||||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="425" cy="-40.75" rx="27" ry="18"/>
|
<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>
|
</g>
|
||||||
<!-- veins->jira -->
|
<!-- veins->jira -->
|
||||||
<g id="edge4" class="edge">
|
<g id="edge6" class="edge">
|
||||||
<title>veins->jira</title>
|
<title>veins->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"/>
|
<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"/>
|
<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">
|
<g id="node10" class="node">
|
||||||
<title>google</title>
|
<title>google</title>
|
||||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="504" cy="-40.75" rx="33.82" ry="18"/>
|
<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>
|
</g>
|
||||||
<!-- veins->google -->
|
<!-- veins->google -->
|
||||||
<g id="edge5" class="edge">
|
<g id="edge7" class="edge">
|
||||||
<title>veins->google</title>
|
<title>veins->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"/>
|
<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"/>
|
<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">
|
<g id="node11" class="node">
|
||||||
<title>slack</title>
|
<title>slack</title>
|
||||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="584" cy="-40.75" rx="27.74" ry="18"/>
|
<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>
|
</g>
|
||||||
<!-- veins->slack -->
|
<!-- veins->slack -->
|
||||||
<g id="edge6" class="edge">
|
<g id="edge8" class="edge">
|
||||||
<title>veins->slack</title>
|
<title>veins->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"/>
|
<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"/>
|
<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">
|
<g id="node13" class="node">
|
||||||
<title>app_be</title>
|
<title>app_be</title>
|
||||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="906" cy="-40.75" rx="38.96" ry="18"/>
|
<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>
|
</g>
|
||||||
<!-- tools->app_be -->
|
<!-- tools->app_be -->
|
||||||
<g id="edge8" class="edge">
|
<g id="edge9" class="edge">
|
||||||
<title>tools->app_be</title>
|
<title>tools->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"/>
|
<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"/>
|
<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>
|
<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="#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"/>
|
<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>
|
</g>
|
||||||
<!-- monitors->app_db -->
|
<!-- monitors->app_db -->
|
||||||
<g id="edge9" class="edge">
|
<g id="edge10" class="edge">
|
||||||
<title>monitors->app_db</title>
|
<title>monitors->app_db</title>
|
||||||
<path fill="none" stroke="#666666" stroke-dasharray="5,2" d="M997,-128.86C997,-111.64 997,-88.42 997,-70.29"/>
|
<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"/>
|
<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 |
92
docs/graphs/themes/dark.gvpr
Normal 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; }
|
||||||
|
}
|
||||||
114
docs/graphs/themes/lucid.gvpr
Normal 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; }
|
||||||
|
}
|
||||||
@@ -1,45 +1,39 @@
|
|||||||
digraph wrapping {
|
digraph wrapping {
|
||||||
bgcolor="#0a0a0a"
|
|
||||||
rankdir=LR
|
rankdir=LR
|
||||||
fontname="Helvetica"
|
fontname="Helvetica"
|
||||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
|
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
|
||||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
|
edge [fontname="Helvetica" fontsize=9]
|
||||||
|
|
||||||
label="Sidebar Injection — How Wrapping Works"
|
label="Sidebar Injection — How Wrapping Works"
|
||||||
labelloc=t
|
labelloc=t
|
||||||
fontsize=14
|
fontsize=14
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
browser [label="Browser" fillcolor="#1a1a1a" shape=oval]
|
browser [label="Browser" shape=oval]
|
||||||
|
|
||||||
subgraph cluster_nginx {
|
subgraph cluster_nginx {
|
||||||
label="Nginx (reverse proxy)"
|
label="Nginx (reverse proxy)"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#d4a574"
|
class="accent"
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
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 {
|
subgraph cluster_app {
|
||||||
label="Managed App"
|
label="Managed App"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#333"
|
|
||||||
fontcolor="#666"
|
|
||||||
|
|
||||||
frontend [label="Frontend\n(React/Next/Vue)" fillcolor="#1a1a1a"]
|
frontend [label="Frontend\n(React/Next/Vue)"]
|
||||||
backend [label="Backend API" fillcolor="#1a1a1a"]
|
backend [label="Backend API"]
|
||||||
}
|
}
|
||||||
|
|
||||||
subgraph cluster_spr {
|
subgraph cluster_spr {
|
||||||
label="Soleprint"
|
label="Soleprint"
|
||||||
style=dashed
|
style=dashed
|
||||||
color="#d4a574"
|
class="accent"
|
||||||
fontcolor="#d4a574"
|
|
||||||
|
|
||||||
sidebar_css [label="sidebar.css" fillcolor="#1a1a1a"]
|
sidebar_css [label="sidebar.css"]
|
||||||
sidebar_js [label="sidebar.js" fillcolor="#1a1a1a"]
|
sidebar_js [label="sidebar.js"]
|
||||||
hub [label="Hub API\n/api/sidebar/config" fillcolor="#1a1a1a"]
|
hub [label="Hub API\n/api/sidebar/config"]
|
||||||
}
|
}
|
||||||
|
|
||||||
browser -> proxy [label="myroom.spr.local.ar"]
|
browser -> proxy [label="myroom.spr.local.ar"]
|
||||||
@@ -47,8 +41,8 @@ digraph wrapping {
|
|||||||
proxy -> hub [label="/spr/ → soleprint"]
|
proxy -> hub [label="/spr/ → soleprint"]
|
||||||
|
|
||||||
// The injection
|
// The injection
|
||||||
proxy -> sidebar_css [label="injects into </head>" color="#d4a574" style=dashed]
|
proxy -> sidebar_css [class="accent" label="injects into </head>" style=dashed]
|
||||||
proxy -> sidebar_js [color="#d4a574" 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"]
|
||||||
}
|
}
|
||||||
|
|||||||
119
docs/graphs/wrapping.lucid.svg
Normal 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->frontend -->
|
||||||
|
<g id="edge2" class="edge">
|
||||||
|
<title>proxy->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->sidebar_css -->
|
||||||
|
<g id="edge3" class="edge accent">
|
||||||
|
<title>proxy->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 </head></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->sidebar_js -->
|
||||||
|
<g id="edge4" class="edge accent">
|
||||||
|
<title>proxy->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->hub -->
|
||||||
|
<g id="edge5" class="edge">
|
||||||
|
<title>proxy->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->hub -->
|
||||||
|
<g id="edge6" class="edge accent">
|
||||||
|
<title>sidebar_js->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->proxy -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>browser->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 |
@@ -10,7 +10,7 @@
|
|||||||
<title>wrapping</title>
|
<title>wrapping</title>
|
||||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-325.41 819.55,-325.41 819.55,4 -4,4"/>
|
<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>
|
<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>
|
<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"/>
|
<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>
|
<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"/>
|
<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>
|
<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>
|
||||||
<g id="clust3" class="cluster">
|
<g id="clust3" class="cluster accent">
|
||||||
<title>cluster_spr</title>
|
<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"/>
|
<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>
|
<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>
|
</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 -->
|
<!-- proxy -->
|
||||||
<g id="node2" class="node">
|
<g id="node1" class="node">
|
||||||
<title>proxy</title>
|
<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"/>
|
<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"/>
|
<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="-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>
|
<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>
|
</g>
|
||||||
<!-- browser->proxy -->
|
|
||||||
<g id="edge1" class="edge">
|
|
||||||
<title>browser->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 -->
|
<!-- frontend -->
|
||||||
<g id="node3" class="node">
|
<g id="node2" class="node">
|
||||||
<title>frontend</title>
|
<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"/>
|
<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>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- sidebar_css -->
|
<!-- sidebar_css -->
|
||||||
<g id="node5" class="node">
|
<g id="node4" class="node">
|
||||||
<title>sidebar_css</title>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- proxy->sidebar_css -->
|
<!-- proxy->sidebar_css -->
|
||||||
<g id="edge4" class="edge">
|
<g id="edge3" class="edge accent">
|
||||||
<title>proxy->sidebar_css</title>
|
<title>proxy->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"/>
|
<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"/>
|
<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 </head></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 </head></text>
|
||||||
</g>
|
</g>
|
||||||
<!-- sidebar_js -->
|
<!-- sidebar_js -->
|
||||||
<g id="node6" class="node">
|
<g id="node5" class="node">
|
||||||
<title>sidebar_js</title>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- proxy->sidebar_js -->
|
<!-- proxy->sidebar_js -->
|
||||||
<g id="edge5" class="edge">
|
<g id="edge4" class="edge accent">
|
||||||
<title>proxy->sidebar_js</title>
|
<title>proxy->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"/>
|
<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"/>
|
<polygon fill="#d4a574" stroke="#d4a574" points="490.6,-51.14 500.3,-46.87 490.06,-44.16 490.6,-51.14"/>
|
||||||
</g>
|
</g>
|
||||||
<!-- hub -->
|
<!-- hub -->
|
||||||
<g id="node7" class="node">
|
<g id="node6" class="node">
|
||||||
<title>hub</title>
|
<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"/>
|
<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="-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>
|
<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>
|
</g>
|
||||||
<!-- proxy->hub -->
|
<!-- proxy->hub -->
|
||||||
<g id="edge3" class="edge">
|
<g id="edge5" class="edge">
|
||||||
<title>proxy->hub</title>
|
<title>proxy->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"/>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- backend -->
|
<!-- backend -->
|
||||||
<g id="node4" class="node">
|
<g id="node3" class="node">
|
||||||
<title>backend</title>
|
<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"/>
|
<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>
|
<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>
|
</g>
|
||||||
<!-- sidebar_js->hub -->
|
<!-- sidebar_js->hub -->
|
||||||
<g id="edge6" class="edge">
|
<g id="edge6" class="edge accent">
|
||||||
<title>sidebar_js->hub</title>
|
<title>sidebar_js->hub</title>
|
||||||
<path fill="none" stroke="#d4a574" d="M570.76,-44.16C597.83,-44.16 636.85,-44.16 670.6,-44.16"/>
|
<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"/>
|
<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->proxy -->
|
||||||
|
<g id="edge1" class="edge">
|
||||||
|
<title>browser->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>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.1 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$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.",
|
"description": "Platform-agnostic model definitions. Portable to TypeScript, Pydantic, Django, Prisma.",
|
||||||
"definitions": {
|
"definitions": {
|
||||||
"Status": {
|
"Status": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="soleprint">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
background: #b91c1c;
|
background: var(--system-accent);
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
section {
|
section {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -59,23 +59,23 @@
|
|||||||
section h2 {
|
section h2 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.composition {
|
.composition {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 2px solid #b91c1c;
|
border: 2px solid var(--system-accent);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.composition h3 {
|
.composition h3 {
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.composition > p {
|
.composition > p {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.components {
|
.components {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -83,20 +83,20 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.component {
|
.component {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
border: 1px solid #3f3f3f;
|
border: 1px solid var(--border-strong);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.component h4 {
|
.component h4 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.component p {
|
.component p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.veins {
|
.veins {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.vein {
|
.vein {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 1px solid #3f3f3f;
|
border: 1px solid var(--border-strong);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -113,16 +113,16 @@
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.vein:hover {
|
.vein:hover {
|
||||||
background: #2a2a2a;
|
background: var(--border);
|
||||||
}
|
}
|
||||||
.vein.selected {
|
.vein.selected {
|
||||||
border-color: #b91c1c;
|
border-color: var(--system-accent);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.vein.active {
|
.vein.active {
|
||||||
background: #b91c1c;
|
background: var(--system-accent);
|
||||||
border-color: #b91c1c;
|
border-color: var(--system-accent);
|
||||||
}
|
}
|
||||||
.vein.active h3 {
|
.vein.active h3 {
|
||||||
color: white;
|
color: white;
|
||||||
@@ -139,12 +139,12 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.vein.disabled:hover {
|
.vein.disabled:hover {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.vein h3 {
|
.vein h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.endpoints {
|
.endpoints {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
.endpoints li {
|
.endpoints li {
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
border-bottom: 1px solid #3f3f3f;
|
border-bottom: 1px solid var(--border-strong);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -164,18 +164,18 @@
|
|||||||
}
|
}
|
||||||
.endpoints code {
|
.endpoints code {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.endpoints .desc {
|
.endpoints .desc {
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
code {
|
code {
|
||||||
background: #2a2a2a;
|
background: var(--border);
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -210,41 +210,41 @@
|
|||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.api-form input[type="text"] {
|
.api-form input[type="text"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #3f3f3f;
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.api-form input[type="text"]:focus,
|
.api-form input[type="text"]:focus,
|
||||||
.api-form select:focus {
|
.api-form select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #b91c1c;
|
border-color: var(--system-accent);
|
||||||
}
|
}
|
||||||
.api-form select {
|
.api-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #3f3f3f;
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.api-form select:disabled {
|
.api-form select:disabled {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
color: #666;
|
color: var(--dim);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.api-form input:disabled {
|
.api-form input:disabled {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
color: #666;
|
color: var(--dim);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.api-controls {
|
.api-controls {
|
||||||
@@ -254,7 +254,7 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.api-controls button {
|
.api-controls button {
|
||||||
background: #b91c1c;
|
background: var(--system-accent);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
@@ -271,17 +271,17 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.tab-button {
|
.tab-button {
|
||||||
background: #1a1a1a !important;
|
background: var(--surface) !important;
|
||||||
border: 1px solid #3f3f3f !important;
|
border: 1px solid var(--border-strong) !important;
|
||||||
color: #e5e5e5 !important;
|
color: var(--text) !important;
|
||||||
}
|
}
|
||||||
.tab-button:hover {
|
.tab-button:hover {
|
||||||
background: #2a2a2a !important;
|
background: var(--border) !important;
|
||||||
}
|
}
|
||||||
.tab-button.active {
|
.tab-button.active {
|
||||||
border-color: white !important;
|
border-color: white !important;
|
||||||
border-width: 2px !important;
|
border-width: 2px !important;
|
||||||
background: #b91c1c !important;
|
background: var(--system-accent) !important;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
.tab-button.active:hover {
|
.tab-button.active:hover {
|
||||||
@@ -291,19 +291,19 @@
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
.epic-status.error {
|
.epic-status.error {
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
color: #0a0a0a;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
color: #b91c1c;
|
color: var(--system-accent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.api-controls label {
|
.api-controls label {
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.output-container {
|
.output-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -324,8 +324,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.output-area {
|
.output-area {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
padding-top: 2.5rem;
|
padding-top: 2.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.output-area.error {
|
.output-area.error {
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.output-area.scrollable {
|
.output-area.scrollable {
|
||||||
max-height: 1000px;
|
max-height: 1000px;
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
.attachments-container h3 {
|
.attachments-container h3 {
|
||||||
color: #fca5a5;
|
color: var(--system-accent-text);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
@@ -370,10 +370,30 @@
|
|||||||
}
|
}
|
||||||
.attachment-label {
|
.attachment-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header style="position: relative">
|
<header style="position: relative">
|
||||||
@@ -393,8 +413,8 @@
|
|||||||
<circle cx="24" cy="20" r="2" fill="currentColor" />
|
<circle cx="24" cy="20" r="2" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
<h1>Artery</h1>
|
<h1>Artery</h1>
|
||||||
{% if pawprint_url %}<a
|
{% if soleprint_url %}<a
|
||||||
href="{{ pawprint_url }}"
|
href="{{ soleprint_url }}"
|
||||||
style="
|
style="
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -913,7 +933,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<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 %}
|
else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -1824,5 +1844,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/theme.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
43
soleprint/artery/plexuses/bundle/plexus.json
Normal 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,15 +7,8 @@ from fastapi.responses import JSONResponse
|
|||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Import datagen from ward/tools
|
from core.config import settings
|
||||||
import sys
|
from datagen import MercadoPagoDataGenerator
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
182
soleprint/artery/shunts/mercadopago/datagen.py
Normal 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)},
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from .api.routes import router
|
# Absolute imports: a shunt is started from its own directory (`python run.py`),
|
||||||
from .core.config import settings
|
# 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(
|
app = FastAPI(
|
||||||
title="MercadoPago (MOCK)",
|
title="MercadoPago (MOCK)",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="soleprint">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
background: #163528;
|
background: #163528;
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
section h2 {
|
section h2 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #86efac;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.composition {
|
.composition {
|
||||||
background: #000000;
|
background: #000000;
|
||||||
@@ -70,12 +70,12 @@
|
|||||||
.composition h3 {
|
.composition h3 {
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #86efac;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.composition > p {
|
.composition > p {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.components {
|
.components {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.component {
|
.component {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
border: 1px solid #6b665e;
|
border: 1px solid #6b665e;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -91,12 +91,12 @@
|
|||||||
.component h4 {
|
.component h4 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #86efac;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.component p {
|
.component p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.books {
|
.books {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.books a {
|
.books a {
|
||||||
color: #86efac;
|
color: var(--system-accent-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -138,6 +138,21 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header style="position: relative">
|
<header style="position: relative">
|
||||||
@@ -265,5 +280,6 @@
|
|||||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
||||||
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="/theme.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Album - Documentation system.
|
Atlas - Documentation system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -11,7 +11,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
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()
|
BASE_DIR = Path(__file__).parent.resolve()
|
||||||
BOOK_DIR = BASE_DIR / "book"
|
BOOK_DIR = BASE_DIR / "book"
|
||||||
@@ -25,24 +25,24 @@ templates = Jinja2Templates(directory=str(BASE_DIR))
|
|||||||
# Serve static files
|
# Serve static files
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
# Pawprint URL for data fetching
|
# The soleprint hub this atlas reads its data from.
|
||||||
PAWPRINT_URL = os.getenv("PAWPRINT_URL", "http://localhost:12000")
|
SOLEPRINT_URL = os.getenv("SOLEPRINT_URL", "http://localhost:12000")
|
||||||
|
|
||||||
|
|
||||||
def get_data():
|
def get_data():
|
||||||
"""Fetch data from pawprint hub."""
|
"""Fetch data from the soleprint hub."""
|
||||||
try:
|
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:
|
if resp.status_code == 200:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to fetch data from pawprint: {e}")
|
print(f"Failed to fetch data from soleprint: {e}")
|
||||||
return {"templates": [], "larders": [], "books": []}
|
return {"templates": [], "depots": [], "books": []}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "service": "album"}
|
return {"status": "ok", "service": "atlas"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -52,7 +52,7 @@ def index(request: Request):
|
|||||||
"index.html",
|
"index.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"pawprint_url": os.getenv("PAWPRINT_EXTERNAL_URL", PAWPRINT_URL),
|
"soleprint_url": os.getenv("SOLEPRINT_EXTERNAL_URL", SOLEPRINT_URL),
|
||||||
**data,
|
**data,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -60,7 +60,7 @@ def index(request: Request):
|
|||||||
|
|
||||||
@app.get("/api/data")
|
@app.get("/api/data")
|
||||||
def api_data():
|
def api_data():
|
||||||
"""API endpoint for frontend data (proxied from pawprint)."""
|
"""API endpoint for frontend data (proxied from soleprint)."""
|
||||||
return get_data()
|
return get_data()
|
||||||
|
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ def feature_form_samples_template():
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Feature Form Template · Album</title>
|
<title>Feature Form Template · Atlas</title>
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
@@ -229,7 +229,7 @@ def feature_form_samples_template():
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<header>
|
||||||
<div class="breadcrumb">
|
<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>
|
</div>
|
||||||
<h1>Feature Form Template</h1>
|
<h1>Feature Form Template</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
@@ -244,7 +244,7 @@ def feature_form_samples_template():
|
|||||||
<div class="form-body">
|
<div class="form-body">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Tipo de Usuario</label>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">Punto de Entrada</label>
|
<label class="field-label">Punto de Entrada</label>
|
||||||
@@ -296,25 +296,25 @@ def feature_form_samples_template():
|
|||||||
return HTMLResponse(html)
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/book/feature-form-samples/larder/", response_class=HTMLResponse)
|
@app.get("/book/feature-form-samples/depot/", response_class=HTMLResponse)
|
||||||
@app.get("/book/feature-form-samples/larder", response_class=HTMLResponse)
|
@app.get("/book/feature-form-samples/depot", response_class=HTMLResponse)
|
||||||
def feature_form_samples_larder():
|
def feature_form_samples_depot():
|
||||||
"""Browse the larder (actual data)"""
|
"""Browse the depot (actual data)"""
|
||||||
html_file = BOOK_DIR / "feature-form-samples" / "index.html"
|
html_file = BOOK_DIR / "feature-form-samples" / "index.html"
|
||||||
if html_file.exists():
|
if html_file.exists():
|
||||||
return HTMLResponse(html_file.read_text())
|
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(
|
@app.get(
|
||||||
"/book/feature-form-samples/larder/{user_type}/{filename}",
|
"/book/feature-form-samples/depot/{user_type}/{filename}",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
)
|
)
|
||||||
def feature_form_samples_detail(request: Request, user_type: str, filename: str):
|
def feature_form_samples_detail(request: Request, user_type: str, filename: str):
|
||||||
"""View a specific feature form"""
|
"""View a specific feature form"""
|
||||||
# Look in the larder subfolder (feature-form)
|
# Look in the depot subfolder (feature-form)
|
||||||
larder_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
|
depot_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
|
||||||
file_path = larder_dir / user_type / filename
|
file_path = depot_dir / user_type / filename
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
||||||
|
|
||||||
|
|||||||
177
soleprint/common/theme/bake.py
Normal 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())
|
||||||
122
soleprint/common/theme/theme.js
Normal 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(),
|
||||||
|
};
|
||||||
|
})();
|
||||||
150
soleprint/common/theme/themes/lucid.css
Normal 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.4–3.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
soleprint/common/theme/themes/mcrn.css
Normal 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;
|
||||||
|
}
|
||||||
113
soleprint/common/theme/themes/soleprint.css
Normal 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;
|
||||||
|
}
|
||||||
221
soleprint/common/theme/tokens.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -4,7 +4,22 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"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": {
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
|||||||
8
soleprint/common/ui/pnpm-workspace.yaml
Normal 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
|
||||||
69
soleprint/common/ui/src/base.css
Normal 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;
|
||||||
|
}
|
||||||
143
soleprint/common/ui/src/components/VideoPlayer.vue
Normal 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>
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
// Framework public API
|
// 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 { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||||
export { SSEDataSource } from './datasources/SSEDataSource'
|
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
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 SplitPane } from './components/SplitPane.vue'
|
||||||
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||||
export type { ConfigField } from './components/ParameterEditor.vue'
|
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||||
|
export { default as VideoPlayer } from './components/VideoPlayer.vue'
|
||||||
|
|
||||||
// Renderers
|
// Renderers
|
||||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||||
|
|||||||
11
soleprint/common/ui/src/theme.css
Normal 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';
|
||||||
37
soleprint/common/ui/vite.config.ts
Normal 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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="soleprint">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
/* Sidebar styles */
|
/* Sidebar styles */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border-right: 1px solid #333;
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -52,16 +52,16 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
a.sidebar-item:hover {
|
a.sidebar-item:hover {
|
||||||
background: #333;
|
background: var(--border);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
background: #d4a574;
|
background: var(--accent);
|
||||||
color: #0a0a0a;
|
color: var(--bg);
|
||||||
}
|
}
|
||||||
.sidebar-item svg {
|
.sidebar-item svg {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
.sidebar-divider {
|
.sidebar-divider {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: #333;
|
background: var(--border);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.sidebar-icon {
|
.sidebar-icon {
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
.sidebar-item .tooltip {
|
.sidebar-item .tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 70px;
|
left: 70px;
|
||||||
background: #333;
|
background: var(--border);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -121,18 +121,18 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
.tagline {
|
.tagline {
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid var(--border);
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
.mission {
|
.mission {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border-left: 3px solid #d4a574;
|
border-left: 3px solid var(--accent);
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
border-radius: 0 8px 8px 0;
|
border-radius: 0 8px 8px 0;
|
||||||
color: #d4a574;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.systems {
|
.systems {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -171,11 +171,11 @@
|
|||||||
.system-info p {
|
.system-info p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artery {
|
.artery {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 1px solid #b91c1c;
|
border: 1px solid #b91c1c;
|
||||||
}
|
}
|
||||||
.artery h2 {
|
.artery h2 {
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.atlas {
|
.atlas {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 1px solid #15803d;
|
border: 1px solid #15803d;
|
||||||
}
|
}
|
||||||
.atlas h2 {
|
.atlas h2 {
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.station {
|
.station {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 1px solid #1d4ed8;
|
border: 1px solid #1d4ed8;
|
||||||
}
|
}
|
||||||
.station h2 {
|
.station h2 {
|
||||||
@@ -210,9 +210,9 @@
|
|||||||
footer {
|
footer {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
border-top: 1px solid #333;
|
border-top: 1px solid var(--border);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: var(--dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.showcase-container {
|
.showcase-container {
|
||||||
@@ -224,8 +224,8 @@
|
|||||||
}
|
}
|
||||||
.showcase-link {
|
.showcase-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: linear-gradient(135deg, #d4a574, #b8956a);
|
background: linear-gradient(135deg, var(--accent), var(--accent-dim));
|
||||||
color: #0a0a0a;
|
color: var(--bg);
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -237,18 +237,33 @@
|
|||||||
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
|
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
|
||||||
}
|
}
|
||||||
.showcase-hint {
|
.showcase-hint {
|
||||||
color: #666;
|
color: var(--dim);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.showcase-hint:hover {
|
.showcase-hint:hover {
|
||||||
color: #d4a574;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% if managed %}
|
{% if managed %}
|
||||||
<link rel="stylesheet" href="/sidebar.css">
|
<link rel="stylesheet" href="/sidebar.css">
|
||||||
<script src="/sidebar.js"></script>
|
<script src="/sidebar.js"></script>
|
||||||
{% endif %}
|
{% 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>
|
</head>
|
||||||
<body{% if managed %} class="has-sidebar"{% endif %}>
|
<body{% if managed %} class="has-sidebar"{% endif %}>
|
||||||
|
|
||||||
@@ -428,5 +443,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>soleprint</footer>
|
<footer>soleprint</footer>
|
||||||
|
<script src="/theme.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ pydantic>=2.5.0
|
|||||||
pydantic-settings>=2.0.0
|
pydantic-settings>=2.0.0
|
||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
jinja2>=3.1.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)
|
# Database (databrowse)
|
||||||
sqlalchemy>=2.0.0
|
sqlalchemy>=2.0.0
|
||||||
|
|||||||
164
soleprint/run.py
@@ -249,19 +249,41 @@ def load_config() -> dict:
|
|||||||
return {}
|
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:
|
def get_system_config(system_key: str) -> dict:
|
||||||
"""Get system configuration by key (data_flow, documentation, execution)."""
|
"""Get system configuration by key (data_flow, documentation, execution)."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
for system in config.get("systems", []):
|
for system in config.get("systems", []):
|
||||||
if system.get("key") == system_key:
|
if system.get("key") == system_key:
|
||||||
return system
|
return SafeDict(system)
|
||||||
return {}
|
return SafeDict()
|
||||||
|
|
||||||
|
|
||||||
def get_components(system_key: str) -> dict:
|
def get_components(system_key: str) -> dict:
|
||||||
"""Get component definitions for a system."""
|
"""Get component definitions for a system."""
|
||||||
config = load_config()
|
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]:
|
def load_data(filename: str) -> list[dict]:
|
||||||
@@ -461,6 +483,41 @@ def atlas_route(path: str):
|
|||||||
# === Station ===
|
# === 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)
|
||||||
@app.get("/station/", response_class=HTMLResponse)
|
@app.get("/station/", response_class=HTMLResponse)
|
||||||
def station_index(request: Request):
|
def station_index(request: Request):
|
||||||
@@ -486,6 +543,7 @@ def station_index(request: Request):
|
|||||||
d["slug"] = d["name"]
|
d["slug"] = d["name"]
|
||||||
d["title"] = d["name"].replace("-", " ").title()
|
d["title"] = d["name"].replace("-", " ").title()
|
||||||
d["status"] = "ready"
|
d["status"] = "ready"
|
||||||
|
cabinets = load_station_cabinets()
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
template = Template(html_path.read_text())
|
template = Template(html_path.read_text())
|
||||||
@@ -497,6 +555,7 @@ def station_index(request: Request):
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
monitors=monitors,
|
monitors=monitors,
|
||||||
desks=desks,
|
desks=desks,
|
||||||
|
cabinets=cabinets,
|
||||||
soleprint_url="/",
|
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:
|
try:
|
||||||
from station.tools.tester.api import router as tester_router
|
_module = importlib.import_module(f"station.tools.{_tool}.api")
|
||||||
app.include_router(tester_router, prefix="/station")
|
app.include_router(_module.router, prefix="/station")
|
||||||
except ImportError as e:
|
except Exception as e:
|
||||||
print(f"Warning: Could not load tester router: {e}")
|
print(f"Warning: Could not load {_tool} 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}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/station/{path:path}")
|
@app.get("/station/{path:path}")
|
||||||
@@ -541,6 +594,77 @@ def station_route(path: str):
|
|||||||
# === Sidebar Wrapper (served at /spr/* when proxied) ===
|
# === 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")
|
@app.get("/sidebar.css")
|
||||||
def sidebar_css():
|
def sidebar_css():
|
||||||
"""Serve sidebar CSS for injection."""
|
"""Serve sidebar CSS for injection."""
|
||||||
|
|||||||
91
soleprint/station/cabinets/README.md
Normal 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.
|
||||||
22
soleprint/station/cabinets/airflow/cabinet.json
Normal 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
37
soleprint/station/cabinets/airflow/service.yml
Normal 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
|
||||||
20
soleprint/station/cabinets/postgres/cabinet.json
Normal 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}"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
soleprint/station/cabinets/postgres/service.yml
Normal 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
|
||||||
17
soleprint/station/cabinets/redis/cabinet.json
Normal 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."
|
||||||
|
]
|
||||||
|
}
|
||||||
14
soleprint/station/cabinets/redis/service.yml
Normal 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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="soleprint">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family:
|
font-family:
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
background: #1d4ed8;
|
background: var(--system-accent);
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
section {
|
section {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -59,23 +59,23 @@
|
|||||||
section h2 {
|
section h2 {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #93c5fd;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.composition {
|
.composition {
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 2px solid #1d4ed8;
|
border: 2px solid var(--system-accent);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.composition h3 {
|
.composition h3 {
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #93c5fd;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.composition > p {
|
.composition > p {
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.components {
|
.components {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -83,20 +83,20 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
.component {
|
.component {
|
||||||
background: #0a0a0a;
|
background: var(--bg);
|
||||||
border: 1px solid #3f3f3f;
|
border: 1px solid var(--border-strong);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.component h4 {
|
.component h4 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: #93c5fd;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.component p {
|
.component p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.tables {
|
.tables {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
}
|
}
|
||||||
.tables li {
|
.tables li {
|
||||||
padding: 0.75rem 0;
|
padding: 0.75rem 0;
|
||||||
border-bottom: 1px solid #3f3f3f;
|
border-bottom: 1px solid var(--border-strong);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -116,32 +116,32 @@
|
|||||||
.tables .name {
|
.tables .name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #e5e5e5;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.tables a.name:hover {
|
.tables a.name:hover {
|
||||||
color: #93c5fd;
|
color: var(--system-accent-text);
|
||||||
}
|
}
|
||||||
.status {
|
.status {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background: #2a2a2a;
|
background: var(--border);
|
||||||
color: #a3a3a3;
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.health {
|
.health {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: #1a1a1a;
|
background: var(--surface);
|
||||||
border: 1px solid #3f3f3f;
|
border: 1px solid var(--border-strong);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: #93c5fd;
|
color: var(--system-accent-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.health:hover {
|
.health:hover {
|
||||||
background: #2a2a2a;
|
background: var(--border);
|
||||||
}
|
}
|
||||||
footer {
|
footer {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
@@ -157,6 +157,25 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header style="position: relative">
|
<header style="position: relative">
|
||||||
@@ -217,6 +236,20 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>{{ (components.watcher.plural or 'monitors')|title }}</h2>
|
<h2>{{ (components.watcher.plural or 'monitors')|title }}</h2>
|
||||||
<ul class="tables">
|
<ul class="tables">
|
||||||
@@ -263,5 +296,6 @@
|
|||||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
||||||
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="/theme.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,26 +15,26 @@ This monitor provides at-a-glance views of the database grouped by test-relevant
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Follows pawprint **book/larder pattern**:
|
Follows the book/depot pattern:
|
||||||
- **larder/** contains all data files (schema, views, scenarios)
|
- **depot/** contains all data files (schema, views, scenarios)
|
||||||
- **main.py** generates SQL queries from view definitions
|
- **main.py** generates SQL queries from view definitions
|
||||||
- Two modes: **SQL** (direct queries) and **API** (Django backend, placeholder)
|
- Two modes: **SQL** (direct queries) and **API** (Django backend, placeholder)
|
||||||
|
|
||||||
### Key Concepts
|
### Key Concepts
|
||||||
|
|
||||||
**Schema** (`larder/schema.json`)
|
**Schema** (`depot/schema.json`)
|
||||||
- AMAR data model with SQL table mappings
|
- AMAR data model with SQL table mappings
|
||||||
- Regular fields (from database columns)
|
- Regular fields (from database columns)
|
||||||
- Computed fields (SQL expressions)
|
- Computed fields (SQL expressions)
|
||||||
- Support for multiple graph generators
|
- Support for multiple graph generators
|
||||||
|
|
||||||
**Views** (`larder/views.json`)
|
**Views** (`depot/views.json`)
|
||||||
- Define what to display and how to group it
|
- Define what to display and how to group it
|
||||||
- Each view targets an entity (User, PetOwner, Veterinarian, etc.)
|
- Each view targets an entity (User, PetOwner, Veterinarian, etc.)
|
||||||
- Can group results (e.g., by role, by data state, by availability)
|
- Can group results (e.g., by role, by data state, by availability)
|
||||||
- SQL is generated automatically from view configuration
|
- SQL is generated automatically from view configuration
|
||||||
|
|
||||||
**Scenarios** (`larder/scenarios.json`)
|
**Scenarios** (`depot/scenarios.json`)
|
||||||
- Test scenarios emerge from actual usage
|
- Test scenarios emerge from actual usage
|
||||||
- Format defined, real scenarios added as needed
|
- Format defined, real scenarios added as needed
|
||||||
- Links scenarios to specific views with filters
|
- Links scenarios to specific views with filters
|
||||||
@@ -49,21 +49,21 @@ Follows pawprint **book/larder pattern**:
|
|||||||
## Running Locally
|
## Running Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/mariano/wdir/ama/pawprint/ward/monitor/data_browse
|
cd soleprint/station/monitors/databrowse
|
||||||
python main.py
|
python main.py
|
||||||
# Opens on http://localhost:12020
|
# Opens on http://localhost:12020
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with uvicorn:
|
Or with uvicorn:
|
||||||
```bash
|
```bash
|
||||||
uvicorn ward.monitor.data_browse.main:app --port 12020 --reload
|
uvicorn station.monitors.databrowse.main:app --port 12020 --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Database connection (defaults to local dev)
|
# Database connection (defaults to local dev)
|
||||||
export NEST_NAME=local
|
export ROOM_NAME=local
|
||||||
export DB_HOST=localhost
|
export DB_HOST=localhost
|
||||||
export DB_PORT=5433
|
export DB_PORT=5433
|
||||||
export DB_NAME=amarback
|
export DB_NAME=amarback
|
||||||
@@ -85,7 +85,7 @@ GET /api/scenarios # Test scenarios (JSON)
|
|||||||
|
|
||||||
## Adding New Views
|
## Adding New Views
|
||||||
|
|
||||||
Edit `larder/views.json`:
|
Edit `depot/views.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -112,7 +112,7 @@ The SQL query is automatically generated from:
|
|||||||
|
|
||||||
## Adding Computed Fields
|
## Adding Computed Fields
|
||||||
|
|
||||||
Edit `larder/schema.json` in the entity definition:
|
Edit `depot/schema.json` in the entity definition:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"computed": {
|
"computed": {
|
||||||
@@ -127,7 +127,7 @@ Computed fields can be used in views just like regular fields.
|
|||||||
|
|
||||||
## Adding Test Scenarios
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -153,8 +153,8 @@ As you identify test patterns, add them to `larder/scenarios.json`:
|
|||||||
|
|
||||||
```
|
```
|
||||||
data_browse/
|
data_browse/
|
||||||
├── larder/
|
├── depot/
|
||||||
│ ├── .larder # Larder marker (book pattern)
|
│ ├── .depot # Depot marker (book pattern)
|
||||||
│ ├── schema.json # AMAR data model with SQL mappings
|
│ ├── schema.json # AMAR data model with SQL mappings
|
||||||
│ ├── views.json # View configurations
|
│ ├── views.json # View configurations
|
||||||
│ └── scenarios.json # Test scenarios
|
│ └── scenarios.json # Test scenarios
|
||||||
|
|||||||
@@ -245,7 +245,7 @@
|
|||||||
|
|
||||||
{% if views|length == 0 %}
|
{% if views|length == 0 %}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
No views configured. Add views to larder/views.json
|
No views configured. Add views to depot/views.json
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
<div class="empty">
|
<div class="empty">
|
||||||
No scenarios defined yet. Scenarios emerge from usage and
|
No scenarios defined yet. Scenarios emerge from usage and
|
||||||
conversations.
|
conversations.
|
||||||
<br />Add them to larder/scenarios.json as you identify test
|
<br />Add them to depot/scenarios.json as you identify test
|
||||||
patterns.
|
patterns.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
- Generate realistic test data for Amar domain models
|
instance — so no client's domain vocabulary lives here.
|
||||||
- Generate mock API responses for external services (MercadoPago, etc.)
|
|
||||||
- Can be plugged into any nest (test suites, mock veins, seeders)
|
|
||||||
- Domain-agnostic and reusable
|
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
datagen/
|
datagen/
|
||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── amar.py # Amar domain models (petowner, pet, cart, etc.)
|
├── base.py # BaseDataGenerator — the contract
|
||||||
├── mercadopago.py # MercadoPago API responses
|
├── api.py # FastAPI router, mounted at /tools/datagen
|
||||||
└── README.md # This file
|
├── 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
|
```python
|
||||||
from ward.tools.datagen.amar import AmarDataGenerator
|
from faker import Faker
|
||||||
|
|
||||||
def test_petowner_creation():
|
# Guarded so the file also runs on its own, outside a built instance —
|
||||||
owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234")
|
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
|
||||||
assert owner_data["address"] == "Av. Corrientes 1234"
|
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
|
def product(self, category=None, **kwargs):
|
||||||
from ward.tools.datagen.amar import AmarDataGenerator
|
return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs}
|
||||||
|
|
||||||
# Create 10 test pet owners
|
|
||||||
for i in range(10):
|
|
||||||
owner = AmarDataGenerator.petowner(is_guest=False)
|
|
||||||
# Save to database...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design Principles
|
Drop it in `cfg/<room>/soleprint/station/tools/datagen/<name>.py` and rebuild the room.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## 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)
|
## HTTP API
|
||||||
- `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
|
|
||||||
|
|
||||||
### 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
|
With one generator loaded, `generator` can be omitted everywhere — the only one is used.
|
||||||
- `payment()` - Payment (Checkout API/Bricks)
|
|
||||||
- `merchant_order()` - Merchant order
|
|
||||||
- `oauth_token()` - OAuth token exchange
|
|
||||||
- `webhook_notification()` - Webhook payloads
|
|
||||||
|
|
||||||
## Examples
|
```bash
|
||||||
|
curl -X POST localhost:12000/tools/datagen/api/generate \
|
||||||
### Generate a complete turnero flow
|
-H 'content-type: application/json' \
|
||||||
|
-d '{"model": "user", "count": 3}'
|
||||||
```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"])
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Future Generators
|
## Design principles
|
||||||
|
|
||||||
- `google.py` - Google API responses (Calendar, Sheets)
|
1. **Room-owned** — domain vocabulary lives in `cfg/<room>/`, never in core.
|
||||||
- `whatsapp.py` - WhatsApp API responses
|
2. **Convention over registration** — a method name is a model name.
|
||||||
- `slack.py` - Slack API responses
|
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.
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="soleprint">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>datagen — Test Data Generator</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0a0a0a;
|
|
||||||
--surface: #1a1a1a;
|
|
||||||
--border: #333;
|
|
||||||
--text: #e5e5e5;
|
|
||||||
--muted: #a3a3a3;
|
|
||||||
--dim: #666;
|
|
||||||
--amber: #d4a574;
|
--amber: #d4a574;
|
||||||
--amber-dim: #b8956a;
|
--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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -493,5 +500,6 @@ document.addEventListener('keydown', e => {
|
|||||||
|
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/theme.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -130,10 +130,19 @@ def _convert_modelgen(loader: Any, source: str) -> dict:
|
|||||||
fields = []
|
fields = []
|
||||||
for field in model_def.fields:
|
for field in model_def.fields:
|
||||||
type_str = _type_str(field.type_hint)
|
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
|
# FK: type name that matches another model
|
||||||
if type_str in all_names:
|
elif type_str in all_names:
|
||||||
fk_target = type_str
|
fk_target = type_str
|
||||||
relationships.append({
|
relationships.append({
|
||||||
"from_model": model_def.name,
|
"from_model": model_def.name,
|
||||||
@@ -145,10 +154,12 @@ def _convert_modelgen(loader: Any, source: str) -> dict:
|
|||||||
elif type_str == "FK":
|
elif type_str == "FK":
|
||||||
fk_target = None # target unknown from extractor
|
fk_target = None # target unknown from extractor
|
||||||
|
|
||||||
|
is_pk = getattr(field, "primary_key", False) or field.name == "id"
|
||||||
|
|
||||||
fields.append({
|
fields.append({
|
||||||
"name": field.name,
|
"name": field.name,
|
||||||
"type": type_str,
|
"type": type_str,
|
||||||
"pk": field.name == "id",
|
"pk": is_pk,
|
||||||
"fk": fk_target,
|
"fk": fk_target,
|
||||||
"m2m": type_str == "M2M",
|
"m2m": type_str == "M2M",
|
||||||
"nullable": field.optional,
|
"nullable": field.optional,
|
||||||
|
|||||||
@@ -1,27 +1,33 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="soleprint">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>graphgen — Schema Explorer</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0a0a0a;
|
|
||||||
--surface: #1a1a1a;
|
|
||||||
--border: #333;
|
|
||||||
--text: #e5e5e5;
|
|
||||||
--muted: #a3a3a3;
|
|
||||||
--dim: #666;
|
|
||||||
--amber: #d4a574;
|
--amber: #d4a574;
|
||||||
--amber-dim: #b8956a;
|
--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; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: var(--font-ui);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -725,5 +731,6 @@ function svgEl(tag) {
|
|||||||
applyViewport();
|
applyViewport();
|
||||||
init();
|
init();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/theme.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Generates typed models from various sources to various output formats.
|
|||||||
Input sources:
|
Input sources:
|
||||||
- Configuration files (soleprint config.json style)
|
- Configuration files (soleprint config.json style)
|
||||||
- Python dataclasses in schema/ folder
|
- 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:
|
Output formats:
|
||||||
- pydantic: Pydantic BaseModel classes
|
- pydantic: Pydantic BaseModel classes
|
||||||
@@ -14,15 +15,17 @@ Output formats:
|
|||||||
- typescript: TypeScript interfaces
|
- typescript: TypeScript interfaces
|
||||||
- protobuf: Protocol Buffer definitions
|
- protobuf: Protocol Buffer definitions
|
||||||
- prisma: Prisma schema
|
- prisma: Prisma schema
|
||||||
|
- schema: graphgen-compatible schema.json (portable schema source)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
|
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 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 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
|
python -m soleprint.station.tools.modelgen list-formats
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.2.0"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
from .generator import GENERATORS, BaseGenerator
|
from .generator import GENERATORS, BaseGenerator
|
||||||
from .loader import ConfigLoader, load_config
|
from .loader import ConfigLoader, load_config
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ Generates typed models from various sources to various formats.
|
|||||||
Input sources:
|
Input sources:
|
||||||
- from-config: Configuration files (soleprint config.json style)
|
- from-config: Configuration files (soleprint config.json style)
|
||||||
- from-schema: Python dataclasses in schema/ folder
|
- 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:
|
Output formats:
|
||||||
- pydantic: Pydantic BaseModel classes
|
- pydantic: Pydantic BaseModel classes
|
||||||
@@ -14,12 +17,16 @@ Output formats:
|
|||||||
- typescript: TypeScript interfaces
|
- typescript: TypeScript interfaces
|
||||||
- protobuf: Protocol Buffer definitions
|
- protobuf: Protocol Buffer definitions
|
||||||
- prisma: Prisma schema
|
- prisma: Prisma schema
|
||||||
|
- schema: graphgen-compatible schema.json
|
||||||
|
- datagen: BaseDataGenerator subclass for station's datagen tool
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python -m soleprint.station.tools.modelgen --help
|
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-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 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 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
|
python -m soleprint.station.tools.modelgen generate --config schema/modelgen.json
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -178,6 +185,135 @@ def cmd_extract(args):
|
|||||||
print("Done!")
|
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):
|
def cmd_generate(args):
|
||||||
"""Generate all targets from a JSON config file."""
|
"""Generate all targets from a JSON config file."""
|
||||||
import json
|
import json
|
||||||
@@ -337,6 +473,108 @@ def main():
|
|||||||
)
|
)
|
||||||
extract_parser.set_defaults(func=cmd_extract)
|
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)
|
# generate command (config-driven multi-target)
|
||||||
gen_parser = subparsers.add_parser(
|
gen_parser = subparsers.add_parser(
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ Supported generators:
|
|||||||
- ProtobufGenerator: Protocol Buffer definitions
|
- ProtobufGenerator: Protocol Buffer definitions
|
||||||
- PrismaGenerator: Prisma schema
|
- PrismaGenerator: Prisma schema
|
||||||
- StrawberryGenerator: Strawberry type/input/enum classes
|
- StrawberryGenerator: Strawberry type/input/enum classes
|
||||||
|
- DatagenGenerator: BaseDataGenerator subclass for station's datagen tool
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Type
|
from typing import Dict, Type
|
||||||
|
|
||||||
from .base import BaseGenerator
|
from .base import BaseGenerator
|
||||||
|
from .datagen import DatagenGenerator
|
||||||
from .django import DjangoGenerator
|
from .django import DjangoGenerator
|
||||||
|
from .jsonschema import JsonSchemaGenerator
|
||||||
from .prisma import PrismaGenerator
|
from .prisma import PrismaGenerator
|
||||||
from .protobuf import ProtobufGenerator
|
from .protobuf import ProtobufGenerator
|
||||||
from .pydantic import PydanticGenerator
|
from .pydantic import PydanticGenerator
|
||||||
@@ -32,15 +35,20 @@ GENERATORS: Dict[str, Type[BaseGenerator]] = {
|
|||||||
"proto": ProtobufGenerator, # Alias
|
"proto": ProtobufGenerator, # Alias
|
||||||
"prisma": PrismaGenerator,
|
"prisma": PrismaGenerator,
|
||||||
"strawberry": StrawberryGenerator,
|
"strawberry": StrawberryGenerator,
|
||||||
|
"schema": JsonSchemaGenerator,
|
||||||
|
"jsonschema": JsonSchemaGenerator, # Alias
|
||||||
|
"datagen": DatagenGenerator,
|
||||||
}
|
}
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseGenerator",
|
"BaseGenerator",
|
||||||
|
"DatagenGenerator",
|
||||||
"PydanticGenerator",
|
"PydanticGenerator",
|
||||||
"DjangoGenerator",
|
"DjangoGenerator",
|
||||||
"StrawberryGenerator",
|
"StrawberryGenerator",
|
||||||
"TypeScriptGenerator",
|
"TypeScriptGenerator",
|
||||||
"ProtobufGenerator",
|
"ProtobufGenerator",
|
||||||
"PrismaGenerator",
|
"PrismaGenerator",
|
||||||
|
"JsonSchemaGenerator",
|
||||||
"GENERATORS",
|
"GENERATORS",
|
||||||
]
|
]
|
||||||
|
|||||||
399
soleprint/station/tools/modelgen/generator/datagen.py
Normal 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
|
||||||
117
soleprint/station/tools/modelgen/generator/jsonschema.py
Normal 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
|
||||||
@@ -27,8 +27,9 @@ class ProtobufGenerator(BaseGenerator):
|
|||||||
if hasattr(models, "grpc_messages"):
|
if hasattr(models, "grpc_messages"):
|
||||||
# SchemaLoader with gRPC definitions
|
# SchemaLoader with gRPC definitions
|
||||||
content = self._generate_from_loader(models)
|
content = self._generate_from_loader(models)
|
||||||
elif isinstance(models, tuple) and len(models) >= 3:
|
elif isinstance(models, tuple) and len(models) >= 2:
|
||||||
# (messages, service_def) tuple
|
# (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])
|
content = self._generate_from_definitions(models[0], models[1])
|
||||||
elif isinstance(models, list):
|
elif isinstance(models, list):
|
||||||
# List of dataclasses (MPR style)
|
# List of dataclasses (MPR style)
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ Supported loaders:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .config import ConfigLoader, load_config
|
from .config import ConfigLoader, load_config
|
||||||
from .extract import EXTRACTORS, BaseExtractor, DjangoExtractor
|
from .extract import (
|
||||||
|
EXTRACTORS,
|
||||||
|
BaseExtractor,
|
||||||
|
DjangoExtractor,
|
||||||
|
OpenAPIExtractor,
|
||||||
|
TabularExtractor,
|
||||||
|
)
|
||||||
from .schema import (
|
from .schema import (
|
||||||
|
DatasetDefinition,
|
||||||
|
EndpointDefinition,
|
||||||
EnumDefinition,
|
EnumDefinition,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
GrpcServiceDefinition,
|
GrpcServiceDefinition,
|
||||||
@@ -30,8 +38,12 @@ __all__ = [
|
|||||||
"FieldDefinition",
|
"FieldDefinition",
|
||||||
"EnumDefinition",
|
"EnumDefinition",
|
||||||
"GrpcServiceDefinition",
|
"GrpcServiceDefinition",
|
||||||
|
"EndpointDefinition",
|
||||||
|
"DatasetDefinition",
|
||||||
# Extractors
|
# Extractors
|
||||||
"BaseExtractor",
|
"BaseExtractor",
|
||||||
"DjangoExtractor",
|
"DjangoExtractor",
|
||||||
|
"OpenAPIExtractor",
|
||||||
|
"TabularExtractor",
|
||||||
"EXTRACTORS",
|
"EXTRACTORS",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
Extractors - Extract model definitions from existing codebases.
|
Extractors - Extract model definitions from existing codebases.
|
||||||
|
|
||||||
Supported frameworks:
|
Supported sources:
|
||||||
- Django: Extract from Django ORM models
|
- Django: Extract from Django ORM models
|
||||||
- SQLAlchemy: Extract from SQLAlchemy models (planned)
|
- SQLAlchemy: Extract from SQLAlchemy models
|
||||||
- Prisma: Extract from Prisma schema (planned)
|
- 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 typing import Dict, Type
|
||||||
|
|
||||||
from .base import BaseExtractor
|
from .base import BaseExtractor
|
||||||
from .django import DjangoExtractor
|
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]] = {
|
EXTRACTORS: Dict[str, Type[BaseExtractor]] = {
|
||||||
"django": DjangoExtractor,
|
"django": DjangoExtractor,
|
||||||
|
"sqlalchemy": SqlAlchemyExtractor,
|
||||||
|
"openapi": OpenAPIExtractor,
|
||||||
|
"tabular": TabularExtractor,
|
||||||
}
|
}
|
||||||
|
|
||||||
__all__ = ["BaseExtractor", "DjangoExtractor", "EXTRACTORS"]
|
__all__ = [
|
||||||
|
"BaseExtractor",
|
||||||
|
"DjangoExtractor",
|
||||||
|
"SqlAlchemyExtractor",
|
||||||
|
"OpenAPIExtractor",
|
||||||
|
"TabularExtractor",
|
||||||
|
"EXTRACTORS",
|
||||||
|
]
|
||||||
|
|||||||
192
soleprint/station/tools/modelgen/loader/extract/database.py
Normal 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)
|
||||||
440
soleprint/station/tools/modelgen/loader/extract/openapi.py
Normal 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
|
||||||
@@ -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))
|
||||||
450
soleprint/station/tools/modelgen/loader/extract/tabular.py
Normal 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
|
||||||
@@ -13,7 +13,7 @@ Expects the folder to have an __init__.py that exports:
|
|||||||
import dataclasses as dc
|
import dataclasses as dc
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
||||||
@@ -27,6 +27,15 @@ class FieldDefinition:
|
|||||||
type_hint: Any
|
type_hint: Any
|
||||||
default: Any = dc.MISSING
|
default: Any = dc.MISSING
|
||||||
optional: bool = False
|
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
|
@dataclass
|
||||||
@@ -55,6 +64,51 @@ class GrpcServiceDefinition:
|
|||||||
methods: List[Dict[str, Any]]
|
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:
|
class SchemaLoader:
|
||||||
"""Loads model definitions from Python dataclasses in schema/ folder."""
|
"""Loads model definitions from Python dataclasses in schema/ folder."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "soleprint-modelgen"
|
name = "soleprint-modelgen"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Multi-source, multi-target model code generator"
|
description = "Multi-source, multi-target model code generator"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = []
|
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]
|
[project.scripts]
|
||||||
modelgen = "modelgen.__main__:main"
|
modelgen = "modelgen.__main__:main"
|
||||||
|
|
||||||
|
|||||||
18
soleprint/station/tools/modelgen/tests/__init__.py
Normal 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).
|
||||||
|
"""
|
||||||
406
soleprint/station/tools/modelgen/tests/test_extractors.py
Normal 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()
|
||||||
@@ -61,6 +61,11 @@ PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
|||||||
"list": lambda base: f"List[{_get_list_inner(base)}]",
|
"list": lambda base: f"List[{_get_list_inner(base)}]",
|
||||||
"enum": lambda base: base.__name__,
|
"enum": lambda base: base.__name__,
|
||||||
"dataclass": 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,
|
"list": _resolve_ts_list,
|
||||||
"enum": lambda base: base.__name__,
|
"enum": lambda base: base.__name__,
|
||||||
"dataclass": 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="sidebar.css">
|
||||||
<style>
|
<style>
|
||||||
/* Demo page styles */
|
/* Demo page styles */
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
transition: margin-right 0.3s ease;
|
transition: margin-right 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pawprint-sidebar.expanded ~ #demo-content {
|
#spr-sidebar.expanded ~ #demo-content {
|
||||||
margin-right: var(--sidebar-width);
|
margin-right: var(--sidebar-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,14 +105,14 @@
|
|||||||
|
|
||||||
<div id="demo-content">
|
<div id="demo-content">
|
||||||
<div class="demo-header">
|
<div class="demo-header">
|
||||||
<h1>🐾 Pawprint Wrapper</h1>
|
<h1>Sidebar Wrapper</h1>
|
||||||
<p>Development tools sidebar for any pawprint-managed nest</p>
|
<p>Development tools sidebar for any soleprint-managed room</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="demo-section">
|
<div class="demo-section">
|
||||||
<h2>👋 Quick Start</h2>
|
<h2>👋 Quick Start</h2>
|
||||||
<p>
|
<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
|
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>
|
<span class="kbd">Ctrl</span> + <span class="kbd">Shift</span> + <span class="kbd">P</span>
|
||||||
to open the sidebar.
|
to open the sidebar.
|
||||||
|
|||||||
127
soleprint/station/tools/shuntgen/README.md
Normal 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.
|
||||||
29
soleprint/station/tools/shuntgen/__init__.py
Normal 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"]
|
||||||
239
soleprint/station/tools/shuntgen/__main__.py
Normal 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()
|
||||||
354
soleprint/station/tools/shuntgen/api.py
Normal 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),
|
||||||
|
}
|
||||||
462
soleprint/station/tools/shuntgen/emit.py
Normal 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))
|
||||||
147
soleprint/station/tools/shuntgen/fixtures/petstore.yaml
Normal 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
|
||||||
@@ -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
|
||||||
|