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/
|
||||
|
||||
# Secrets. There was no rule here, which is how a real API key ended up tracked
|
||||
# in station/tools/tester/.env. Templates still ship.
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Built library bundles (regenerate with `pnpm build` in the package).
|
||||
# The dist is what a container boots; it is an artifact, not source.
|
||||
dist/
|
||||
|
||||
# Generated runnable instance (entirely gitignored - regenerate with build.py)
|
||||
gen/
|
||||
|
||||
# Specs and sheets uploaded through shuntgen's UI. Inputs someone dropped in a
|
||||
# browser, not source — and often a client's real data.
|
||||
soleprint/station/tools/shuntgen/uploads/
|
||||
|
||||
# Room configurations (separate repo - contains credentials and room-specific data)
|
||||
# Keep cfg/standalone/ and cfg/sample/ as templates, ignore actual rooms
|
||||
cfg/amar/
|
||||
|
||||
147
CLAUDE.md
@@ -51,20 +51,19 @@ spr/
|
||||
│ └── amar/ # Amar room config
|
||||
│ ├── config.json
|
||||
│ ├── data/
|
||||
│ ├── artery/ # Amar-specific (merged into output)
|
||||
│ │ └── shunts/amar/
|
||||
│ ├── atlas/ # Amar-specific books
|
||||
│ │ └── books/
|
||||
│ ├── station/ # Amar-specific tools config
|
||||
│ │ └── tools/datagen/
|
||||
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
|
||||
│ │ ├── artery/ # room shunts, pulses
|
||||
│ │ │ └── shunts/amar/
|
||||
│ │ ├── atlas/ # room books
|
||||
│ │ │ └── books/
|
||||
│ │ ├── station/ # room tool configs
|
||||
│ │ │ └── tools/datagen/
|
||||
│ │ └── nginx/
|
||||
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
|
||||
│ ├── link/ # Bridge to managed app
|
||||
│ ├── soleprint/ # Soleprint docker config
|
||||
│ ├── databrowse/
|
||||
│ ├── tester/
|
||||
│ ├── monitors/
|
||||
│ └── models/
|
||||
│ └── amar/ # The managed app itself
|
||||
│
|
||||
├── ctrl/ # Build/run scripts
|
||||
├── ctrl/ # Build/run scripts (see Build & Run)
|
||||
│
|
||||
└── gen/ # Built instances (gitignored)
|
||||
├── standalone/
|
||||
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
|
||||
- `config.json` - Framework branding/terminology
|
||||
- `data/` - Data files (veins.json, shunts.json, etc.)
|
||||
|
||||
Room-specific system configs (merged into output):
|
||||
- `artery/` - Room-specific shunts, pulses
|
||||
- `atlas/` - Room-specific books
|
||||
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
|
||||
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
|
||||
the core `soleprint/` tree at build time:
|
||||
- `soleprint/artery/` - Room-specific shunts, pulses
|
||||
- `soleprint/atlas/` - Room-specific books
|
||||
- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests)
|
||||
|
||||
A room's own lifecycle scripts live in `cfg/<room>/ctrl/` and land in
|
||||
`gen/<room>/ctrl/` — that is what `make start <room>` dispatches to.
|
||||
|
||||
## Build & Run
|
||||
|
||||
`make` is the front door — one target per `ctrl/` script, with the subcommand as an
|
||||
argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts,
|
||||
never in the Makefile. `make help` lists every target.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
python build.py # -> gen/standalone/
|
||||
python build.py --cfg amar # -> gen/amar/
|
||||
python build.py --all # -> all rooms
|
||||
make # = make help
|
||||
make build [room|all|models] # -> gen/<room>/ (default: standalone)
|
||||
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
|
||||
make stop [room]
|
||||
make cluster [up|down|status] # the shared `spr` kind cluster
|
||||
make component [list|sync|watch|publish|diff]
|
||||
make deploy [--build|--sync-only]
|
||||
```
|
||||
|
||||
# Run bare-metal
|
||||
cd gen/standalone && python run.py
|
||||
Every script stays runnable on its own — the standalone rule holds:
|
||||
|
||||
# Using ctrl scripts
|
||||
./ctrl/build.sh [room]
|
||||
./ctrl/start.sh [room] [-d]
|
||||
./ctrl/stop.sh [room]
|
||||
```bash
|
||||
python build.py --cfg amar # -> gen/amar/
|
||||
cd gen/standalone && python run.py # bare-metal
|
||||
./ctrl/kind-up.sh # still works directly
|
||||
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
|
||||
```
|
||||
|
||||
## Adding a New Room
|
||||
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
|
||||
cp cfg/standalone/config.json cfg/newroom/
|
||||
cp -r cfg/standalone/data/* cfg/newroom/data/
|
||||
|
||||
# Add room-specific configs as needed:
|
||||
# cfg/newroom/artery/shunts/...
|
||||
# cfg/newroom/atlas/books/...
|
||||
# cfg/newroom/station/tools/...
|
||||
# Add room-specific configs as needed (note the soleprint/ overlay level):
|
||||
# cfg/newroom/soleprint/artery/shunts/...
|
||||
# cfg/newroom/soleprint/atlas/books/...
|
||||
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
|
||||
|
||||
python build.py --cfg newroom
|
||||
make build newroom
|
||||
```
|
||||
|
||||
## Ports
|
||||
@@ -149,10 +160,51 @@ python build.py --cfg newroom
|
||||
|------|---------|
|
||||
| modelgen | Generate models from config |
|
||||
| datagen | Generate test data (uses faker) |
|
||||
| tester | BDD/playwright test runner |
|
||||
| tester | HTTP contract test runner |
|
||||
| graphgen | Generate navigable model graphs |
|
||||
| databrowse | SQL data browser |
|
||||
|
||||
## Shared UI (`soleprint/common/ui`)
|
||||
|
||||
Upstream copy of the `soleprint-ui` package. `mpr/ui/framework` and
|
||||
`meetus/ui/framework` are **downstream copies** synced by `ctrl/spr.py` — edit here,
|
||||
never there, or the next sync overwrites the change.
|
||||
|
||||
```bash
|
||||
cd soleprint/common/ui
|
||||
pnpm install && pnpm typecheck && pnpm test
|
||||
pnpm build # -> dist/soleprint-ui.js + dist/style.css
|
||||
```
|
||||
|
||||
**The theme ships with the bundle.** Every component styles itself with
|
||||
`var(--surface-0)` and friends from `src/tokens.css`, so `src/index.ts` imports
|
||||
`src/theme.css` (tokens + `base.css`). A bundle without it renders broken, not merely
|
||||
unbranded. Retheme by overriding the variables in `tokens.css`; don't inline colours
|
||||
in components.
|
||||
|
||||
`dist/` and `node_modules/` are gitignored — the dist is an artifact a container
|
||||
boots, not source. To hand someone a running artifact without the sources:
|
||||
|
||||
```bash
|
||||
python ctrl/spr.py publish soleprint-ui <dest> --dist # bundle + manifest only
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
**No test bodies are committed to core.** `soleprint/station/tools/tester/` ships the
|
||||
base class, runner and UI; tests belong to a room at
|
||||
`cfg/<room>/soleprint/station/tools/tester/tests/`.
|
||||
|
||||
Two rules, both in `tester/tests/test_template.py`:
|
||||
|
||||
1. **One suite, any environment** — a test states what the API promises, never who
|
||||
implements it or where it runs.
|
||||
2. **No helper framework tools** — stdlib `unittest` + `httpx`. No pytest fixtures,
|
||||
no framework test client, no factories, no ORM or database access.
|
||||
|
||||
Django, where it appears, is an optional private DB editor via its admin — never the
|
||||
framework, and never something a test reaches into.
|
||||
|
||||
## External Paths
|
||||
|
||||
| What | Path |
|
||||
@@ -161,6 +213,35 @@ python build.py --cfg newroom
|
||||
|
||||
## Files Ignored
|
||||
|
||||
- `gen/` - Regenerate with `python build.py`
|
||||
- `gen/` - Regenerate with `make build [room]`
|
||||
- `dist/` - Build artifact; regenerate with `pnpm build`
|
||||
- `node_modules/`
|
||||
- `fails/`, `def/` - Drafts
|
||||
- `__pycache__/`, `.venv/`
|
||||
|
||||
## Known Broken (found, not yet fixed)
|
||||
|
||||
- `tester/tests/example/test_health.py` imports `pytest`, which is neither a
|
||||
dependency nor allowed here — `tests/README.md` says stdlib `unittest` and
|
||||
`httpx`, no pytest. It fails to import, so `python -m tester discover` reports
|
||||
it as `_FailedTest`. It also duplicates `test_template.py`, which is the
|
||||
sanctioned example. Removing it is probably the fix; core ships no tests.
|
||||
- Old vocabulary survives in prose — `album`, `larder`, `ward`, `nest` and
|
||||
`pawprint` still appear in READMEs, `docs/`, and this file's history. Live code
|
||||
is clean; the docs lag.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **A real API key was tracked** in `station/tools/tester/.env`. Untracked, `.env`
|
||||
added to `.gitignore`, `.env.example` added. **The key is still in git history
|
||||
and must be rotated** — untracking does not unpublish it.
|
||||
- Client vocabulary is out of live code. `atlas/main.py` was written entirely as
|
||||
`Album`/`larder`/`PAWPRINT_URL`; `databrowse` docs described a `larder/` the
|
||||
code stopped using; `get-api-key.sh` defaulted to the client's database name.
|
||||
- Two dead back-links, found while renaming: `atlas/main.py` passed
|
||||
`pawprint_url` and `artery/index.html` read `pawprint_url`, while `run.py`
|
||||
passes `soleprint_url`. Neither "← Soleprint" link had ever rendered.
|
||||
- `atlas/main.py` fetched `/api/data/album`; `main.py` serves `/api/data/atlas`.
|
||||
`get_data()` had been failing and returning empty lists.
|
||||
- `tester/tests/_dev/test_health.py` imported a non-existent `..endpoints`. The
|
||||
import was unused; removing it makes the module discoverable (2 tests).
|
||||
|
||||
62
Makefile
Normal file
@@ -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)
|
||||
|
||||
|
||||
def load_cabinets(room: str) -> list[dict]:
|
||||
"""The dependency containers a room asked for, in the order it listed them.
|
||||
|
||||
Read from cfg/<room>/data/cabinets.json — the same shape and the same place
|
||||
as its sibling data/*.json files, so nothing new has to know about it.
|
||||
Entries are {"name": "postgres"} and may carry an "env" override.
|
||||
"""
|
||||
path = SPR_ROOT / "cfg" / room / "data" / "cabinets.json"
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
entries = json.loads(path.read_text())
|
||||
except ValueError as e:
|
||||
log.warning(f" cabinets.json is not valid JSON, ignoring: {e}")
|
||||
return []
|
||||
|
||||
out = []
|
||||
for entry in entries if isinstance(entries, list) else []:
|
||||
if isinstance(entry, str):
|
||||
entry = {"name": entry}
|
||||
if isinstance(entry, dict) and entry.get("name"):
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_cabinets(requested: list[dict]) -> list[dict]:
|
||||
"""Expand each request into its definition, pulling in what it depends on.
|
||||
|
||||
Airflow without postgres is a container that exits on boot, so a cabinet's
|
||||
depends_on is added for you rather than left as something to remember.
|
||||
"""
|
||||
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
|
||||
resolved: dict[str, dict] = {}
|
||||
|
||||
def add(name: str, overrides: dict) -> None:
|
||||
if name in resolved:
|
||||
# Already pulled in as somebody's dependency. The room asking for it
|
||||
# by name is the more specific statement, so its env still applies —
|
||||
# otherwise declaring airflow before postgres would silently drop
|
||||
# postgres's settings.
|
||||
if overrides.get("env"):
|
||||
resolved[name]["env"] = {
|
||||
**resolved[name].get("env", {}),
|
||||
**overrides["env"],
|
||||
}
|
||||
return
|
||||
definition_path = cabinets_dir / name / "cabinet.json"
|
||||
if not definition_path.exists():
|
||||
available = sorted(
|
||||
p.name for p in cabinets_dir.iterdir() if p.is_dir()
|
||||
) if cabinets_dir.exists() else []
|
||||
log.warning(f" no such cabinet: {name} (available: {', '.join(available) or 'none'})")
|
||||
return
|
||||
try:
|
||||
definition = json.loads(definition_path.read_text())
|
||||
except ValueError as e:
|
||||
log.warning(f" cabinet {name} has invalid cabinet.json: {e}")
|
||||
return
|
||||
|
||||
# Mark it claimed before recursing, so a dependency cycle terminates.
|
||||
resolved[name] = definition
|
||||
for dependency in definition.get("depends_on", []) or []:
|
||||
add(dependency, {})
|
||||
|
||||
definition["env"] = {**definition.get("env", {}), **overrides.get("env", {})}
|
||||
|
||||
for entry in requested:
|
||||
add(entry["name"], entry)
|
||||
|
||||
# Dependencies first, so compose reads in the order things start.
|
||||
ordered: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def emit(name: str) -> None:
|
||||
if name in seen or name not in resolved:
|
||||
return
|
||||
seen.add(name)
|
||||
for dependency in resolved[name].get("depends_on", []) or []:
|
||||
emit(dependency)
|
||||
ordered.append(resolved[name])
|
||||
|
||||
for name in resolved:
|
||||
emit(name)
|
||||
return ordered
|
||||
|
||||
|
||||
def compose_cabinets(output_dir: Path, room: str):
|
||||
"""Merge the room's cabinets into its docker-compose.yml and .env.example.
|
||||
|
||||
This is the compile step for dependencies: a room declares postgres, and the
|
||||
built instance comes out with postgres in its compose file rather than with
|
||||
instructions for adding it.
|
||||
"""
|
||||
requested = load_cabinets(room)
|
||||
if not requested:
|
||||
return
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
log.warning(
|
||||
" cabinets need PyYAML to merge into docker-compose.yml "
|
||||
"(pip install pyyaml) — skipping"
|
||||
)
|
||||
return
|
||||
|
||||
cabinets = resolve_cabinets(requested)
|
||||
if not cabinets:
|
||||
return
|
||||
|
||||
compose_path = output_dir / "docker-compose.yml"
|
||||
if not compose_path.exists():
|
||||
log.warning(
|
||||
f" no docker-compose.yml in {output_dir.name}, "
|
||||
f"so there is nothing to merge {len(cabinets)} cabinet(s) into"
|
||||
)
|
||||
return
|
||||
|
||||
original = compose_path.read_text()
|
||||
# A YAML round-trip drops every comment, and the room's compose file leads
|
||||
# with the one that says how to run it. Keep the header block; the rest is
|
||||
# generated anyway.
|
||||
header = []
|
||||
for line in original.splitlines():
|
||||
if line.startswith("#") or not line.strip():
|
||||
header.append(line)
|
||||
else:
|
||||
break
|
||||
while header and not header[-1].strip():
|
||||
header.pop()
|
||||
|
||||
compose = yaml.safe_load(original) or {}
|
||||
services = compose.setdefault("services", {})
|
||||
volumes = compose.setdefault("volumes", {}) or {}
|
||||
cabinets_dir = SPR_ROOT / "soleprint" / "station" / "cabinets"
|
||||
|
||||
added, skipped = [], []
|
||||
for cabinet in cabinets:
|
||||
name = cabinet["name"]
|
||||
service_name = cabinet.get("service", name)
|
||||
|
||||
# The room's own compose file is the authority. A room that already
|
||||
# declares `db` has arranged it deliberately, and silently replacing it
|
||||
# would be the worst possible outcome of switching a cabinet on.
|
||||
if service_name in services:
|
||||
skipped.append(service_name)
|
||||
continue
|
||||
|
||||
fragment_path = cabinets_dir / name / "service.yml"
|
||||
if not fragment_path.exists():
|
||||
log.warning(f" cabinet {name} has no service.yml")
|
||||
continue
|
||||
|
||||
fragment = yaml.safe_load(fragment_path.read_text()) or {}
|
||||
for key, value in fragment.items():
|
||||
if key in services:
|
||||
skipped.append(key)
|
||||
continue
|
||||
services[key] = value
|
||||
added.append(key)
|
||||
|
||||
for volume in cabinet.get("volumes", []) or []:
|
||||
volumes.setdefault(volume, None)
|
||||
|
||||
if volumes:
|
||||
compose["volumes"] = volumes
|
||||
|
||||
rendered = yaml.safe_dump(compose, sort_keys=False, default_flow_style=False)
|
||||
banner = f"# Cabinets merged in by build.py: {', '.join(c['name'] for c in cabinets)}.\n"
|
||||
preamble = ("\n".join(header) + "\n" + banner + "\n") if header else banner + "\n"
|
||||
compose_path.write_text(preamble + rendered)
|
||||
|
||||
if added:
|
||||
log.info(f" cabinets: {', '.join(added)}")
|
||||
if skipped:
|
||||
log.info(f" cabinets already declared by the room, left alone: {', '.join(skipped)}")
|
||||
|
||||
_append_cabinet_env(output_dir, cabinets)
|
||||
|
||||
|
||||
def _append_cabinet_env(output_dir: Path, cabinets: list[dict]):
|
||||
"""Add each cabinet's settings to .env.example, without touching .env."""
|
||||
example = output_dir / ".env.example"
|
||||
existing = example.read_text() if example.exists() else ""
|
||||
# Match whole settings, not substrings: `POSTGRES_DB=` appears inside
|
||||
# `MY_POSTGRES_DB=`, and a substring test would decide the setting was
|
||||
# already there and skip it.
|
||||
declared = {
|
||||
line.split("=", 1)[0].strip()
|
||||
for line in existing.splitlines()
|
||||
if "=" in line and not line.lstrip().startswith("#")
|
||||
}
|
||||
|
||||
lines = []
|
||||
for cabinet in cabinets:
|
||||
env = cabinet.get("env", {})
|
||||
if not env:
|
||||
continue
|
||||
block = [f"\n# ── {cabinet.get('title', cabinet['name'])} (cabinet) ──"]
|
||||
for note in cabinet.get("notes", []) or []:
|
||||
block.append(f"# {note}")
|
||||
wrote = False
|
||||
for key, value in env.items():
|
||||
if key in declared:
|
||||
continue
|
||||
block.append(f"{key}={value}")
|
||||
declared.add(key)
|
||||
wrote = True
|
||||
if wrote:
|
||||
lines.extend(block)
|
||||
|
||||
if lines:
|
||||
example.write_text(existing.rstrip("\n") + "\n" + "\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def build_soleprint(output_dir: Path, room: str):
|
||||
"""Build soleprint folder with core + room config merged."""
|
||||
soleprint = SPR_ROOT / "soleprint"
|
||||
@@ -348,6 +563,11 @@ def build_soleprint(output_dir: Path, room: str):
|
||||
# Room config (includes merging room-specific artery/atlas/station)
|
||||
copy_cfg(output_dir, room)
|
||||
|
||||
# Dependency containers the room asked for, merged into its compose file.
|
||||
# After copy_cfg, because the compose file being merged into is the room's.
|
||||
log.info("Composing cabinets...")
|
||||
compose_cabinets(output_dir, room)
|
||||
|
||||
# Generate models
|
||||
log.info("Generating models...")
|
||||
if not generate_models(output_dir, room):
|
||||
@@ -419,7 +639,11 @@ def main():
|
||||
elif args.all:
|
||||
build(SPR_ROOT / "gen" / "standalone", None)
|
||||
for room in (SPR_ROOT / "cfg").iterdir():
|
||||
if room.is_dir() and room.name not in ("__pycache__", "standalone"):
|
||||
# cfg/ is itself a git repo and rooms may carry dot-dirs — skip them,
|
||||
# or --all tries to build ".git" as a room.
|
||||
if room.name.startswith(".") or room.name == "__pycache__":
|
||||
continue
|
||||
if room.is_dir() and room.name != "standalone":
|
||||
build(SPR_ROOT / "gen" / room.name, room.name)
|
||||
else:
|
||||
if args.output:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Pawprint Data Layer
|
||||
Soleprint Data Layer
|
||||
|
||||
JSON file storage (future: MongoDB)
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Pawprint Data Layer
|
||||
Soleprint Data Layer
|
||||
|
||||
JSON file storage (future: MongoDB)
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"items": [
|
||||
{"name": "pawprint-local", "slug": "pawprint-local", "title": "Pawprint Local", "status": "dev", "config_path": "deploy/pawprint-local"}
|
||||
{"name": "standalone-local", "slug": "standalone-local", "title": "Standalone Local", "status": "dev", "config_path": "deploy/standalone-local"}
|
||||
]
|
||||
}
|
||||
|
||||
37
ctrl/build.sh
Executable file
@@ -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 watch soleprint-ui ~/wdir/unt/ui/framework # ctrl+c to stop
|
||||
python ctrl/spr.py publish soleprint-ui ~/wdir/mpr/ui/framework
|
||||
python ctrl/spr.py publish soleprint-ui /tmp/out --dist # built bundle only
|
||||
python ctrl/spr.py diff soleprint-ui ~/wdir/mpr/ui/framework
|
||||
"""
|
||||
|
||||
@@ -194,6 +195,37 @@ def cmd_list(args):
|
||||
log.info(" %s %s v%-10s %s", f"{name:<25}", f"{comp_type:<5}", version, entry["path"])
|
||||
|
||||
|
||||
def publish_dist(source, dest):
|
||||
"""Copy only the built bundle — dist/ plus the manifest and any docs.
|
||||
|
||||
The artifact-only form: a consumer gets something that runs, not the
|
||||
sources. Deliberately NOT a variant of copy_tree, which walks the whole
|
||||
tree and strips dist; here dist is the entire point.
|
||||
"""
|
||||
dist = source / "dist"
|
||||
if not dist.is_dir() or not any(dist.iterdir()):
|
||||
log.error("no build at %s", dist)
|
||||
log.info("build it first: cd %s && pnpm build", source)
|
||||
sys.exit(1)
|
||||
|
||||
count = 0
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for item in dist.rglob("*"):
|
||||
if item.is_file():
|
||||
target = dest / "dist" / item.relative_to(dist)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, target)
|
||||
count += 1
|
||||
|
||||
# The manifest travels too, or the bundle's entry points aren't resolvable.
|
||||
for name in ("package.json", "README.md", "LICENSE"):
|
||||
if (source / name).is_file():
|
||||
shutil.copy2(source / name, dest / name)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def cmd_publish(args):
|
||||
registry = load_registry()
|
||||
comp_type, source = resolve_component(registry, args.component)
|
||||
@@ -202,12 +234,17 @@ def cmd_publish(args):
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
|
||||
if args.dist:
|
||||
count = publish_dist(source, dest)
|
||||
mode = "published-dist"
|
||||
else:
|
||||
count = copy_tree(source, dest)
|
||||
write_stamp(dest, args.component, comp_type, source, "published")
|
||||
mode = "published"
|
||||
write_stamp(dest, args.component, comp_type, source, mode)
|
||||
|
||||
version = get_version(comp_type, source)
|
||||
sha = get_sha()
|
||||
log.info("%s v%s (%s) -> %s (%d files)", args.component, version, sha, dest, count)
|
||||
log.info("%s v%s (%s) -> %s (%d files, %s)", args.component, version, sha, dest, count, mode)
|
||||
|
||||
|
||||
def cmd_sync(args):
|
||||
@@ -306,6 +343,12 @@ def main():
|
||||
p = sub.add_parser(cmd)
|
||||
p.add_argument("component", help="component name")
|
||||
p.add_argument("dest", help="target folder path")
|
||||
if cmd == "publish":
|
||||
p.add_argument(
|
||||
"--dist",
|
||||
action="store_true",
|
||||
help="ship only the built bundle (dist/ + manifest), not the sources",
|
||||
)
|
||||
|
||||
p = sub.add_parser("watch", help="continuous two-way sync (foreground, ctrl+c to stop)")
|
||||
p.add_argument("component", help="component name")
|
||||
|
||||
35
ctrl/start.sh
Executable file
@@ -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
|
||||
|
||||
Test data generator using faker. Produces realistic, domain-specific data for testing and development.
|
||||
Test data generator. Produces realistic, domain-specific records for testing and
|
||||
development, from generators a room writes or that
|
||||
[modelgen](#station-modelgen) writes for it.
|
||||
|
||||
**Status:** live
|
||||
|
||||
@@ -8,50 +10,95 @@ Test data generator using faker. Produces realistic, domain-specific data for te
|
||||
|
||||
## What It Does
|
||||
|
||||
Datagen generates fake but realistic data. Names, emails, addresses, transactions -- whatever your domain needs. It uses Python's faker library under the hood.
|
||||
Datagen hands out instances of a room's models. Core ships the base class, the
|
||||
discovery, the HTTP API and the browser UI; the generators themselves belong to
|
||||
a room, because what counts as realistic is a property of the domain.
|
||||
|
||||
Core datagen is a placeholder. The real work happens in room-specific generators.
|
||||
Generation is stdlib `random`, `uuid` and `datetime` — **not** faker, which is
|
||||
not a dependency of this repo.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
soleprint/station/tools/datagen/ # Core (base classes, placeholder)
|
||||
cfg/<room>/soleprint/station/tools/datagen/ # Room-specific generators
|
||||
soleprint/station/tools/datagen/ # base class, api, UI
|
||||
cfg/<room>/soleprint/station/tools/datagen/ # the room's generators
|
||||
```
|
||||
|
||||
After build, both merge into `gen/<room>/station/tools/datagen/`.
|
||||
|
||||
## Pattern
|
||||
## The contract
|
||||
|
||||
Rooms subclass a base generator and provide domain-specific data factories:
|
||||
A generator subclasses `BaseDataGenerator` and defines **one method per model,
|
||||
named after it**. There is no registration step: the method name *is* the model
|
||||
name.
|
||||
|
||||
```python
|
||||
from station.tools.datagen.base import BaseGenerator
|
||||
from station.tools.datagen.base import BaseDataGenerator
|
||||
|
||||
class RoomDataGenerator(BaseGenerator):
|
||||
def generate_customers(self, count=10):
|
||||
return [self.fake_customer() for _ in range(count)]
|
||||
class RoomDataGenerator(BaseDataGenerator):
|
||||
def customer(self, **kwargs):
|
||||
return {"id": str(uuid4()), "name": ..., "email": ..., **kwargs}
|
||||
|
||||
def fake_customer(self):
|
||||
return {
|
||||
"name": self.faker.name(),
|
||||
"email": self.faker.email(),
|
||||
"phone": self.faker.phone_number(),
|
||||
}
|
||||
def invoice(self, customer_id=None, **kwargs):
|
||||
return {"id": str(uuid4()), "customer_id": customer_id, **kwargs}
|
||||
```
|
||||
|
||||
The base class provides:
|
||||
|
||||
| Method | Does |
|
||||
| --- | --- |
|
||||
| `generate(model, count=1, **kwargs)` | calls the matching method `count` times; `kwargs` pass through to every call |
|
||||
| `available_models()` | the method names, which are the model names |
|
||||
| `schema()` | override to return a graphgen-compatible schema |
|
||||
|
||||
Discovery is by convention too: any `*.py` in the datagen directory whose first
|
||||
class ends in `Generator` is loaded and keyed by its filename.
|
||||
|
||||
## Generating a generator
|
||||
|
||||
Writing one by hand is optional. modelgen's `datagen` target emits the whole
|
||||
class from a schema — and when the schema came from spreadsheets, the generated
|
||||
class **samples the real rows** rather than inventing values:
|
||||
|
||||
```bash
|
||||
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t datagen
|
||||
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t datagen
|
||||
```
|
||||
|
||||
Each room defines what data it needs. Core provides the faker instance and base class. Rooms provide the factories.
|
||||
This is how [shuntgen](#station-shuntgen) fills a generated shunt.
|
||||
|
||||
## Room Configuration
|
||||
## HTTP API
|
||||
|
||||
Room generators live in `cfg/<room>/soleprint/station/tools/datagen/`. They are fully self-contained -- they define their own models, factories, and output formats.
|
||||
Mounted under `/station/tools/datagen/`:
|
||||
|
||||
The core module provides:
|
||||
- Base generator class with faker instance
|
||||
- CLI entry point
|
||||
- Output formatting (JSON, CSV)
|
||||
| Route | Returns |
|
||||
| --- | --- |
|
||||
| `GET /` | the browser UI |
|
||||
| `GET /api/generators` | loaded generator files and their models |
|
||||
| `GET /api/models?generator=` | model names |
|
||||
| `POST /api/generate` | `{model, count, generator?, kwargs?}` → the records |
|
||||
| `GET /api/schema?generator=` | the generator's schema, if it exposes one |
|
||||
|
||||
## Feeding graphgen
|
||||
|
||||
A generator that overrides `schema()` is surfaced at `/api/schema` in the format
|
||||
[graphgen](#station-graphgen) reads, so the same definition draws the diagram:
|
||||
|
||||
```python
|
||||
def schema(self):
|
||||
return {
|
||||
"models": {
|
||||
"Invoice": {
|
||||
"doc": "A billed order.",
|
||||
"fields": {
|
||||
"id": {"type": "UUID", "pk": True},
|
||||
"customer_id": {"type": "FK:Customer"},
|
||||
"total": {"type": "float"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rooms provide:
|
||||
- Domain-specific generator subclasses
|
||||
- Field definitions and relationships
|
||||
- Volume and distribution configuration
|
||||
`FK:<Model>` and `M2M:<Model>` are how relations are written. modelgen's
|
||||
`datagen` target emits this method for you.
|
||||
|
||||
@@ -1,54 +1,111 @@
|
||||
# Modelgen
|
||||
|
||||
Generates platform-specific models from JSON Schema. Reads schema once, writes models for multiple targets.
|
||||
Multi-source, multi-target model generator. Reads a schema from wherever it
|
||||
already lives, and writes it out for every stack that needs it.
|
||||
|
||||
**Status:** dev
|
||||
**Status:** live
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
Modelgen takes a JSON Schema definition and produces model code for different platforms:
|
||||
Everything passes through one intermediate representation — `ModelDefinition`,
|
||||
`FieldDefinition`, `EnumDefinition`. **Loaders** fill it, **generators** emit
|
||||
from it, and the two sides do not know about each other. Adding an input means
|
||||
one extractor and every output comes with it; adding an output means one
|
||||
generator and every input already feeds it.
|
||||
|
||||
- **Pydantic** -- Python data validation models
|
||||
- **Django ORM** -- Django model classes
|
||||
- **Prisma** -- Prisma schema definitions
|
||||
```
|
||||
dataclasses ─┐ ┌─ pydantic
|
||||
Django │ ├─ django
|
||||
SQLAlchemy ├──▶ ModelDefinition ──▶├─ sqlmodel
|
||||
a live DB │ FieldDefinition ├─ typescript
|
||||
OpenAPI │ EnumDefinition ├─ protobuf
|
||||
CSV/ODS ─┘ ├─ prisma
|
||||
├─ strawberry
|
||||
├─ schema (graphgen)
|
||||
└─ datagen
|
||||
```
|
||||
|
||||
One schema, multiple outputs.
|
||||
Core is **pure standard library**. It is published as `soleprint-modelgen` and
|
||||
installs with no dependencies; live-database extraction is an extra
|
||||
(`pip install "soleprint-modelgen[db]"`), and YAML specs need PyYAML.
|
||||
|
||||
## Extractors
|
||||
## Sources
|
||||
|
||||
Modelgen also works in reverse. Extractors read existing codebases and produce a normalized schema representation:
|
||||
| Command | Reads |
|
||||
| --- | --- |
|
||||
| `from-schema` | Python dataclasses in a `schema/` folder |
|
||||
| `from-config` | a room's `config.json` |
|
||||
| `extract` | a Django or SQLAlchemy codebase (`--framework auto` detects) |
|
||||
| `from-db` | a live database, any SQLAlchemy dialect |
|
||||
| `from-openapi` | an OpenAPI 3.x / Swagger 2.0 document |
|
||||
| `from-tabular` | a directory of `.csv` / `.tsv` / `.ods` spreadsheets |
|
||||
|
||||
- **Django extractor** -- reads Django model files
|
||||
- **SQLAlchemy extractor** -- reads SQLAlchemy model files
|
||||
- **Prisma extractor** -- reads Prisma schema files
|
||||
```bash
|
||||
python -m station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,typescript,schema
|
||||
python -m station.tools.modelgen from-tabular -s ./sheets -o out/ -t pydantic,datagen
|
||||
python -m station.tools.modelgen extract -s /path/to/django -o out/ -t prisma
|
||||
python -m station.tools.modelgen from-db -u postgresql://… -o out/ -t typescript
|
||||
python -m station.tools.modelgen list-formats
|
||||
```
|
||||
|
||||
Extractors feed into graphgen for visualization.
|
||||
### From a spec
|
||||
|
||||
## Output
|
||||
`components.schemas` (or Swagger's `definitions`) become models. `$ref` chains
|
||||
and `allOf` are resolved, enums are materialised as real `Enum` classes so every
|
||||
target names them properly, and a referenced object becomes a relation rather
|
||||
than a nested type — the same call the database extractor makes, and what keeps
|
||||
the generated code valid for every target.
|
||||
|
||||
Generated models are written to `gen/<room>/models/`.
|
||||
The parse also yields the *operations*, which is what
|
||||
[shuntgen](#station-shuntgen) turns into routes.
|
||||
|
||||
```
|
||||
gen/<room>/models/
|
||||
├── pydantic/
|
||||
├── django/
|
||||
└── prisma/
|
||||
```
|
||||
### From spreadsheets
|
||||
|
||||
## CLI
|
||||
One model per CSV file, one per sheet in an ODS workbook. Column types are
|
||||
inferred from the values actually present, and a blank cell makes the column
|
||||
optional. Keys and relations are inferred by name and then confirmed against the
|
||||
data: an `id` column that is not unique is not treated as a key, and
|
||||
`customer_id` is only a foreign key if a `customers` sheet came with it.
|
||||
|
||||
```bash
|
||||
python -m modelgen
|
||||
```
|
||||
The rows are kept, not just the shape — which is what lets the `datagen` target
|
||||
sample real values instead of inventing them.
|
||||
|
||||
Reads from `schema.json` (the project source of truth) and writes to the configured output directory.
|
||||
ODS is read with `zipfile` and `ElementTree`. No odfpy, no pandas: the
|
||||
dependency-free promise is what makes this package publishable on its own.
|
||||
|
||||
## Shared Distribution
|
||||
## Targets
|
||||
|
||||
Modelgen is also distributed as a shared component via `ctrl/spr.py`. This allows other projects to use model generation without running full soleprint.
|
||||
`pydantic`, `django`, `sqlmodel`, `typescript` (`ts`), `protobuf` (`proto`),
|
||||
`prisma`, `strawberry`, `schema` (`jsonschema`), `datagen`.
|
||||
|
||||
## Schema Source
|
||||
Two are worth calling out:
|
||||
|
||||
- **`schema`** writes the graphgen-compatible `schema.json` — the portable
|
||||
artifact [graphgen](#station-graphgen) and databrowse read directly.
|
||||
Relations come out as `FK:<Model>` and `M2M:<Model>`.
|
||||
- **`datagen`** writes a `BaseDataGenerator` subclass for
|
||||
[datagen](#station-datagen), including its `schema()` override. Given
|
||||
spreadsheet rows it samples them; otherwise it synthesises from the types.
|
||||
|
||||
Multiple targets in one run get one file each, named `models_<target><ext>`.
|
||||
|
||||
## In a build
|
||||
|
||||
`build.py` calls modelgen during every room build, writing
|
||||
`gen/<room>/models/pydantic/__init__.py` from the room's `config.json`. See
|
||||
[Export / Compile](#export).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd soleprint/station/tools
|
||||
python -m unittest modelgen.tests.test_extractors
|
||||
```
|
||||
|
||||
The source of truth is `schema.json` at the project root. All model generation starts from this file. Room-specific schema extensions live in `cfg/<room>/models/`.
|
||||
stdlib `unittest`, no pytest, and every input is built in a temp directory — the
|
||||
tests have to pass with nothing installed and nothing else in the tree. Run them
|
||||
from `station/tools/`, not from inside `modelgen/`: the package ships a
|
||||
`types.py`, and putting its own directory on `sys.path` shadows the standard
|
||||
library module of that name.
|
||||
|
||||
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-modelgen", "title": {"en": "↳ Modelgen"}, "sub": true},
|
||||
{"id": "station-graphgen", "title": {"en": "↳ Graphgen"}, "sub": true},
|
||||
{"id": "station-shuntgen", "title": {"en": "↳ Shuntgen"}, "sub": true},
|
||||
{"id": "station-databrowse", "title": {"en": "↳ Databrowse"}, "sub": true},
|
||||
{"id": "station-cabinets", "title": {"en": "↳ Cabinets"}, "sub": true},
|
||||
|
||||
{"id": "components", "title": {"en": "Shared Components"}},
|
||||
{"id": "export", "title": {"en": "Export / Compile"}},
|
||||
{"id": "deployment", "title": {"en": "Deployment"}}
|
||||
]
|
||||
|
||||
67
docs/graphs/README.md
Normal file
@@ -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 {
|
||||
bgcolor="#0a0a0a"
|
||||
rankdir=LR
|
||||
fontname="Helvetica"
|
||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
|
||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#b91c1c"]
|
||||
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
|
||||
edge [class="artery" fontname="Helvetica" fontsize=9]
|
||||
|
||||
label="Artery — Component Hierarchy"
|
||||
labelloc=t
|
||||
fontsize=14
|
||||
fontcolor="#fca5a5"
|
||||
|
||||
vein [label="Vein\nstateless API connector" fillcolor="#1a1a1a"]
|
||||
pulse [label="Pulse\nVein + Room + Depot" fillcolor="#1a1a1a"]
|
||||
plexus [label="Plexus\nfull app: backend\n+ frontend + DB" fillcolor="#1a1a1a"]
|
||||
shunt [label="Shunt\nfake connector\nfor testing" fillcolor="#1a1a1a" color="#d4a574"]
|
||||
vein [label="Vein\nstateless API connector"]
|
||||
pulse [label="Pulse\nVein + Room + Depot"]
|
||||
plexus [label="Plexus\nfull app: backend\n+ frontend + DB"]
|
||||
shunt [class="accent" label="Shunt\nfake connector\nfor testing"]
|
||||
|
||||
vein -> pulse [label="compose"]
|
||||
pulse -> plexus [label="extend"]
|
||||
shunt -> vein [label="replaces" style=dashed color="#d4a574" fontcolor="#d4a574"]
|
||||
shunt -> vein [class="accent" label="replaces" style=dashed]
|
||||
|
||||
// Examples
|
||||
subgraph cluster_examples {
|
||||
label="Live Veins"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9]
|
||||
google [label="Google" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9]
|
||||
ia [label="IA" fillcolor="#1a1a1a" fontcolor="#15803d" fontsize=9]
|
||||
jira [class="ok" label="Jira" fontsize=9]
|
||||
google [class="accent-text" label="Google" fontsize=9]
|
||||
ia [class="ok" label="IA" fontsize=9]
|
||||
}
|
||||
|
||||
subgraph cluster_shunts {
|
||||
label="Shunts"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
mp [label="MercadoPago" fillcolor="#1a1a1a" fontcolor="#d4a574" fontsize=9]
|
||||
mp [class="accent-text" label="MercadoPago" fontsize=9]
|
||||
}
|
||||
|
||||
jira -> vein [style=invis]
|
||||
|
||||
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)
|
||||
-->
|
||||
<!-- Title: artery_hierarchy Pages: 1 -->
|
||||
<svg width="845pt" height="317pt"
|
||||
viewBox="0.00 0.00 845.00 317.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 313.25)">
|
||||
<svg width="845pt" height="294pt"
|
||||
viewBox="0.00 0.00 845.00 294.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 290.25)">
|
||||
<title>artery_hierarchy</title>
|
||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-313.25 840.5,-313.25 840.5,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="418.25" y="-291.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Component Hierarchy</text>
|
||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-290.25 840.5,-290.25 840.5,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="418.25" y="-268.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Artery — Component Hierarchy</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<title>cluster_examples</title>
|
||||
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="135.88,-8 135.88,-193 220.88,-193 220.88,-8 135.88,-8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="178.38" y="-175.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text>
|
||||
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="135.88,-68 135.88,-253 220.88,-253 220.88,-68 135.88,-68"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="178.38" y="-235.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Live Veins</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<title>cluster_shunts</title>
|
||||
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-199 8,-276 100,-276 100,-199 8,-199"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="54" y="-258.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text>
|
||||
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="8,-8 8,-85 100,-85 100,-8 8,-8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="54" y="-67.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Shunts</text>
|
||||
</g>
|
||||
<!-- jira -->
|
||||
<g id="node1" class="node ok">
|
||||
<title>jira</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-112 150.88,-112 150.88,-76 204.88,-76 204.88,-112"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-91.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">Jira</text>
|
||||
</g>
|
||||
<!-- vein -->
|
||||
<g id="node1" class="node">
|
||||
<g id="node5" class="node">
|
||||
<title>vein</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="446,-167 300.25,-167 300.25,-131 446,-131 446,-167"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-152.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</text>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="446,-82 300.25,-82 300.25,-46 446,-46 446,-82"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-67.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="373.12" y="-53.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless API connector</text>
|
||||
</g>
|
||||
<!-- jira->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>
|
||||
<!-- mp->shunt -->
|
||||
<!-- pulse -->
|
||||
<g id="node2" class="node">
|
||||
<g id="node6" class="node">
|
||||
<title>pulse</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="659.25,-167 522.5,-167 522.5,-131 659.25,-131 659.25,-167"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-152.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Pulse</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-138.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</text>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="659.25,-82 522.5,-82 522.5,-46 659.25,-46 659.25,-82"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-67.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Pulse</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="590.88" y="-53.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Vein + Room + Depot</text>
|
||||
</g>
|
||||
<!-- vein->pulse -->
|
||||
<g id="edge1" class="edge">
|
||||
<g id="edge1" class="edge artery">
|
||||
<title>vein->pulse</title>
|
||||
<path fill="none" stroke="#b91c1c" d="M446.27,-149C467.03,-149 489.79,-149 510.95,-149"/>
|
||||
<polygon fill="#b91c1c" stroke="#b91c1c" points="510.67,-152.5 520.67,-149 510.67,-145.5 510.67,-152.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="484.25" y="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">compose</text>
|
||||
<path fill="none" stroke="#b91c1c" d="M446.27,-64C467.03,-64 489.79,-64 510.95,-64"/>
|
||||
<polygon fill="#b91c1c" stroke="#b91c1c" points="510.67,-67.5 520.67,-64 510.67,-60.5 510.67,-67.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="484.25" y="-66.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">compose</text>
|
||||
</g>
|
||||
<!-- plexus -->
|
||||
<g id="node3" class="node">
|
||||
<g id="node7" class="node">
|
||||
<title>plexus</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="836.5,-173.25 726.75,-173.25 726.75,-124.75 836.5,-124.75 836.5,-173.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-158.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Plexus</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-145.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">full app: backend</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-131.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</text>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="836.5,-88.25 726.75,-88.25 726.75,-39.75 836.5,-39.75 836.5,-88.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-73.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Plexus</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-60.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">full app: backend</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="781.62" y="-46.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">+ frontend + DB</text>
|
||||
</g>
|
||||
<!-- pulse->plexus -->
|
||||
<g id="edge2" class="edge">
|
||||
<g id="edge2" class="edge artery">
|
||||
<title>pulse->plexus</title>
|
||||
<path fill="none" stroke="#b91c1c" d="M659.48,-149C677.62,-149 697.19,-149 715.21,-149"/>
|
||||
<polygon fill="#b91c1c" stroke="#b91c1c" points="714.98,-152.5 724.98,-149 714.98,-145.5 714.98,-152.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="693" y="-151.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">extend</text>
|
||||
</g>
|
||||
<!-- shunt -->
|
||||
<g id="node4" class="node">
|
||||
<title>shunt</title>
|
||||
<polygon fill="#1a1a1a" stroke="#d4a574" points="226.75,-249.25 129,-249.25 129,-200.75 226.75,-200.75 226.75,-249.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-234.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Shunt</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-221.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">fake connector</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-207.8" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">for testing</text>
|
||||
<path fill="none" stroke="#b91c1c" d="M659.48,-64C677.62,-64 697.19,-64 715.21,-64"/>
|
||||
<polygon fill="#b91c1c" stroke="#b91c1c" points="714.98,-67.5 724.98,-64 714.98,-60.5 714.98,-67.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="693" y="-66.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">extend</text>
|
||||
</g>
|
||||
<!-- shunt->vein -->
|
||||
<g id="edge3" class="edge">
|
||||
<g id="edge3" class="edge accent">
|
||||
<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"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="315.71,-174.95 323.73,-168.03 313.14,-168.43 315.71,-174.95"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="263.5" y="-200.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text>
|
||||
</g>
|
||||
<!-- jira -->
|
||||
<g id="node5" class="node">
|
||||
<title>jira</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-52 150.88,-52 150.88,-16 204.88,-16 204.88,-52"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-31.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#15803d">Jira</text>
|
||||
</g>
|
||||
<!-- jira->vein -->
|
||||
<!-- google -->
|
||||
<g id="node6" class="node">
|
||||
<title>google</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-106 150.88,-106 150.88,-70 204.88,-70 204.88,-106"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-85.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">Google</text>
|
||||
</g>
|
||||
<!-- ia -->
|
||||
<g id="node7" class="node">
|
||||
<title>ia</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="204.88,-160 150.88,-160 150.88,-124 204.88,-124 204.88,-160"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="177.88" y="-139.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#15803d">IA</text>
|
||||
<path fill="none" stroke="#d4a574" stroke-dasharray="5,2" d="M227.14,-41.49C245.81,-44.39 267.72,-47.79 288.78,-51.06"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="288.11,-54.5 298.53,-52.58 289.18,-47.58 288.11,-54.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="263.5" y="-52.6" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">replaces</text>
|
||||
</g>
|
||||
<!-- mp -->
|
||||
<g id="node8" class="node">
|
||||
<title>mp</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="92,-243 16,-243 16,-207 92,-207 92,-243"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="54" y="-222.07" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">MercadoPago</text>
|
||||
</g>
|
||||
<!-- mp->shunt -->
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
@@ -1,43 +1,37 @@
|
||||
digraph cfg_gen_flow {
|
||||
bgcolor="#0a0a0a"
|
||||
rankdir=LR
|
||||
fontname="Helvetica"
|
||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
|
||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#d4a574"]
|
||||
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
|
||||
edge [class="accent" fontname="Helvetica" fontsize=9]
|
||||
|
||||
label="Build Flow — cfg/ to gen/"
|
||||
labelloc=t
|
||||
fontsize=14
|
||||
fontcolor="#d4a574"
|
||||
|
||||
// Source
|
||||
subgraph cluster_source {
|
||||
label="Source (committed)"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
core [label="soleprint/\ncore framework" fillcolor="#1a1a1a"]
|
||||
cfg [label="cfg/<room>/\nroom config" fillcolor="#1a1a1a"]
|
||||
core [label="soleprint/\ncore framework"]
|
||||
cfg [label="cfg/<room>/\nroom config"]
|
||||
}
|
||||
|
||||
// Build
|
||||
build [label="build.py\n--cfg <room>" fillcolor="#1a1a1a" color="#d4a574" shape=component]
|
||||
build [class="accent" label="build.py\n--cfg <room>" shape=component]
|
||||
|
||||
// Output
|
||||
subgraph cluster_output {
|
||||
label="Output (generated, gitignored)"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
gen_spr [label="gen/<room>/soleprint/\ncore + room merged" fillcolor="#1a1a1a"]
|
||||
gen_app [label="gen/<room>/<app>/\ncloned repos" fillcolor="#1a1a1a"]
|
||||
gen_link [label="gen/<room>/link/\nDB bridge" fillcolor="#1a1a1a"]
|
||||
gen_spr [label="gen/<room>/soleprint/\ncore + room merged"]
|
||||
gen_app [label="gen/<room>/<app>/\ncloned repos"]
|
||||
gen_link [label="gen/<room>/link/\nDB bridge"]
|
||||
}
|
||||
|
||||
// Run
|
||||
docker [label="docker compose up" fillcolor="#1a1a1a" shape=component]
|
||||
docker [label="docker compose up" shape=component]
|
||||
|
||||
// Flow
|
||||
core -> build
|
||||
|
||||
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>
|
||||
</g>
|
||||
<!-- build -->
|
||||
<g id="node3" class="node">
|
||||
<g id="node6" class="node accent">
|
||||
<title>build</title>
|
||||
<polygon fill="#1a1a1a" stroke="#d4a574" points="271.75,-106 181.5,-106 181.5,-102 177.5,-102 177.5,-98 181.5,-98 181.5,-78 177.5,-78 177.5,-74 181.5,-74 181.5,-70 271.75,-70 271.75,-106"/>
|
||||
<polyline fill="none" stroke="#d4a574" points="181.5,-102 185.5,-102 185.5,-98 181.5,-98"/>
|
||||
@@ -37,7 +37,7 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="226.62" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">--cfg <room></text>
|
||||
</g>
|
||||
<!-- core->build -->
|
||||
<g id="edge1" class="edge">
|
||||
<g id="edge1" class="edge accent">
|
||||
<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"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="171.59,-111.76 179.76,-105.02 169.17,-105.19 171.59,-111.76"/>
|
||||
@@ -50,65 +50,65 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="75.75" y="-77.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">room config</text>
|
||||
</g>
|
||||
<!-- cfg->build -->
|
||||
<g id="edge2" class="edge">
|
||||
<g id="edge2" class="edge accent">
|
||||
<title>cfg->build</title>
|
||||
<path fill="none" stroke="#d4a574" d="M119.41,-88C135.12,-88 153.15,-88 169.84,-88"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="169.63,-91.5 179.63,-88 169.63,-84.5 169.63,-91.5"/>
|
||||
</g>
|
||||
<!-- gen_spr -->
|
||||
<g id="node4" class="node">
|
||||
<g id="node3" class="node">
|
||||
<title>gen_spr</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="536.5,-160 394.5,-160 394.5,-124 536.5,-124 536.5,-160"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-145.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/<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>
|
||||
</g>
|
||||
<!-- build->gen_spr -->
|
||||
<g id="edge3" class="edge">
|
||||
<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"/>
|
||||
<!-- 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 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>
|
||||
<!-- gen_app -->
|
||||
<g id="node5" class="node">
|
||||
<g id="node4" class="node">
|
||||
<title>gen_app</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="531.62,-106 399.38,-106 399.38,-70 531.62,-70 531.62,-106"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-91.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/<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>
|
||||
</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 -->
|
||||
<g id="node6" class="node">
|
||||
<g id="node5" class="node">
|
||||
<title>gen_link</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="521.88,-52 409.12,-52 409.12,-16 521.88,-16 521.88,-52"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="465.5" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">gen/<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>
|
||||
</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 -->
|
||||
<g id="edge5" class="edge">
|
||||
<g id="edge5" class="edge accent">
|
||||
<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"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="398.44,-51.86 407.46,-46.31 396.95,-45.03 398.44,-51.86"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-75.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
|
||||
</g>
|
||||
<!-- docker -->
|
||||
<g id="node7" class="node">
|
||||
<title>docker</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="733.5,-160 610.25,-160 610.25,-156 606.25,-156 606.25,-152 610.25,-152 610.25,-132 606.25,-132 606.25,-128 610.25,-128 610.25,-124 733.5,-124 733.5,-160"/>
|
||||
<polyline fill="none" stroke="#333333" points="610.25,-156 614.25,-156 614.25,-152 610.25,-152"/>
|
||||
<polyline fill="none" stroke="#333333" points="610.25,-132 614.25,-132 614.25,-128 610.25,-128"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="671.88" y="-138.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">docker compose up</text>
|
||||
</g>
|
||||
<!-- gen_spr->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"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="315.25" y="-75.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">if managed</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
69
docs/graphs/render.sh
Executable file
@@ -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 {
|
||||
bgcolor="#0a0a0a"
|
||||
rankdir=TB
|
||||
fontname="Helvetica"
|
||||
node [fontname="Helvetica" fontsize=10 style=filled color="#333" fontcolor="#e5e5e5" shape=record]
|
||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
|
||||
node [fontname="Helvetica" fontsize=10 style=filled shape=record]
|
||||
edge [fontname="Helvetica" fontsize=9]
|
||||
|
||||
label="Room Layers — init wizard"
|
||||
labelloc=t
|
||||
fontsize=14
|
||||
fontcolor="#d4a574"
|
||||
|
||||
l0 [label="{Layer 0 | Config + Data | config.json · data/*.json}" fillcolor="#1a1a1a" color="#d4a574"]
|
||||
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}" fillcolor="#1a1a1a"]
|
||||
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}" fillcolor="#1a1a1a"]
|
||||
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}" fillcolor="#1a1a1a"]
|
||||
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}" fillcolor="#1a1a1a"]
|
||||
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}" fillcolor="#1a1a1a"]
|
||||
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}" fillcolor="#1a1a1a"]
|
||||
l0 [class="accent" label="{Layer 0 | Config + Data | config.json · data/*.json}"]
|
||||
l1 [label="{Layer 1 | Docker | soleprint/docker-compose.yml · .env}"]
|
||||
l2 [label="{Layer 2 | Managed App | docker-compose.yml · Dockerfiles · .env}"]
|
||||
l3 [label="{Layer 3 | Link | link/main.py · adapters/ · Dockerfile}"]
|
||||
l4 [label="{Layer 4 | Scripts | ctrl/start.sh · stop.sh · status.sh · logs.sh}"]
|
||||
l5 [label="{Layer 5 | Systems | tester/environments.json · tests/}"]
|
||||
l6 [label="{Layer 6 | Nginx | nginx/local.conf · docker-compose.nginx.yml}"]
|
||||
|
||||
l0 -> l1 [label="required"]
|
||||
l1 -> l2 [label="if managed"]
|
||||
@@ -26,6 +24,6 @@ digraph room_layers {
|
||||
l5 -> l6 [label="if frontend"]
|
||||
|
||||
// Annotations
|
||||
note_req [label="every room" fillcolor="#0a0a0a" fontcolor="#d4a574" color="#0a0a0a" shape=plaintext fontsize=9]
|
||||
note_req [class="accent" label="every room" shape=plaintext fontsize=9]
|
||||
note_req -> l0 [style=invis]
|
||||
}
|
||||
|
||||
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"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="226.31" y="-586.2" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Room Layers — init wizard</text>
|
||||
<!-- l0 -->
|
||||
<g id="node1" class="node">
|
||||
<g id="node1" class="node accent">
|
||||
<title>l0</title>
|
||||
<polygon fill="#1a1a1a" stroke="#d4a574" points="155.12,-442.5 155.12,-504.75 285.88,-504.75 285.88,-442.5 155.12,-442.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-491.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 0</text>
|
||||
@@ -55,7 +55,7 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="191.95" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if managed</text>
|
||||
</g>
|
||||
<!-- l4 -->
|
||||
<g id="node5" class="node">
|
||||
<g id="node4" class="node">
|
||||
<title>l4</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="229.25,-221.5 229.25,-283.75 441.75,-283.75 441.75,-221.5 229.25,-221.5"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="335.5" y="-270.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 4</text>
|
||||
@@ -65,14 +65,14 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="335.5" y="-228.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">ctrl/start.sh · stop.sh · status.sh · logs.sh</text>
|
||||
</g>
|
||||
<!-- l1->l4 -->
|
||||
<g id="edge4" class="edge">
|
||||
<g id="edge3" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="297.43,-294.36 302.3,-284.95 292.63,-289.26 297.43,-294.36"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="299.45" y="-304.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">optional</text>
|
||||
</g>
|
||||
<!-- l3 -->
|
||||
<g id="node4" class="node">
|
||||
<g id="node5" class="node">
|
||||
<title>l3</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="10.88,-111 10.88,-173.25 200.12,-173.25 200.12,-111 10.88,-111"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="105.5" y="-159.75" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">Layer 3</text>
|
||||
@@ -82,7 +82,7 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="105.5" y="-118.25" font-family="Helvetica,sans-Serif" font-size="10.00" fill="#e5e5e5">link/main.py · adapters/ · Dockerfile</text>
|
||||
</g>
|
||||
<!-- l2->l3 -->
|
||||
<g id="edge3" class="edge">
|
||||
<g id="edge4" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="109,-184.92 105.5,-174.92 102,-184.92 109,-184.92"/>
|
||||
@@ -123,10 +123,10 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="359.12" y="-83.95" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">if frontend</text>
|
||||
</g>
|
||||
<!-- note_req -->
|
||||
<g id="node8" class="node">
|
||||
<g id="node8" class="node accent">
|
||||
<title>note_req</title>
|
||||
<polygon fill="#0a0a0a" stroke="none" points="254,-578.25 187,-578.25 187,-542.25 254,-542.25 254,-578.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-557.33" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">every room</text>
|
||||
<polygon fill="#1a1a1a" stroke="none" points="254,-578.25 187,-578.25 187,-542.25 254,-542.25 254,-578.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="220.5" y="-557.33" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#e5e5e5">every room</text>
|
||||
</g>
|
||||
<!-- note_req->l0 -->
|
||||
</g>
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -1,88 +1,78 @@
|
||||
digraph system_overview {
|
||||
bgcolor="#0a0a0a"
|
||||
rankdir=TB
|
||||
compound=true
|
||||
fontname="Helvetica"
|
||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5"]
|
||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
|
||||
node [fontname="Helvetica" fontsize=11 style=filled]
|
||||
edge [fontname="Helvetica" fontsize=9]
|
||||
|
||||
label="Soleprint — System Overview"
|
||||
labelloc=t
|
||||
fontsize=14
|
||||
fontcolor="#d4a574"
|
||||
|
||||
// Core
|
||||
subgraph cluster_core {
|
||||
label="Soleprint Hub"
|
||||
style=dashed
|
||||
color="#d4a574"
|
||||
fontcolor="#d4a574"
|
||||
class="accent"
|
||||
|
||||
hub [label="soleprint\ncore coordinator\nport 12000" fillcolor="#1a1a1a" shape=box]
|
||||
hub [label="soleprint\ncore coordinator\nport 12000" shape=box]
|
||||
}
|
||||
|
||||
// Artery
|
||||
subgraph cluster_artery {
|
||||
label="Artery — Todo lo vital"
|
||||
style=dashed
|
||||
color="#b91c1c"
|
||||
fontcolor="#fca5a5"
|
||||
class="artery"
|
||||
|
||||
veins [label="Veins\nstateless connectors" fillcolor="#1a1a1a"]
|
||||
shunts [label="Shunts\nmock connectors" fillcolor="#1a1a1a"]
|
||||
pulses [label="Pulses\ncomposed flows" fillcolor="#1a1a1a"]
|
||||
veins [label="Veins\nstateless connectors"]
|
||||
shunts [label="Shunts\nmock connectors"]
|
||||
pulses [label="Pulses\ncomposed flows"]
|
||||
}
|
||||
|
||||
// Atlas
|
||||
subgraph cluster_atlas {
|
||||
label="Atlas — Documentacion accionable"
|
||||
style=dashed
|
||||
color="#15803d"
|
||||
fontcolor="#86efac"
|
||||
class="atlas"
|
||||
|
||||
books [label="Books\ndocumentation" fillcolor="#1a1a1a"]
|
||||
templates [label="Templates\npatterns" fillcolor="#1a1a1a"]
|
||||
books [label="Books\ndocumentation"]
|
||||
templates [label="Templates\npatterns"]
|
||||
}
|
||||
|
||||
// Station
|
||||
subgraph cluster_station {
|
||||
label="Station — Centro de control"
|
||||
style=dashed
|
||||
color="#1d4ed8"
|
||||
fontcolor="#93c5fd"
|
||||
class="station"
|
||||
|
||||
tools [label="Tools\ntester · datagen · modelgen" fillcolor="#1a1a1a"]
|
||||
monitors [label="Monitors\ndatabrowse" fillcolor="#1a1a1a"]
|
||||
tools [label="Tools\ntester · datagen · modelgen"]
|
||||
monitors [label="Monitors\ndatabrowse"]
|
||||
}
|
||||
|
||||
// External
|
||||
subgraph cluster_external {
|
||||
label="External APIs"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
jira [label="Jira" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
||||
google [label="Google" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
||||
slack [label="Slack" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
||||
jira [label="Jira"]
|
||||
google [label="Google"]
|
||||
slack [label="Slack"]
|
||||
}
|
||||
|
||||
// Managed app
|
||||
subgraph cluster_managed {
|
||||
label="Managed App"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
app_fe [label="Frontend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
||||
app_be [label="Backend" fillcolor="#1a1a1a" fontcolor="#a3a3a3"]
|
||||
app_db [label="Database" fillcolor="#1a1a1a" fontcolor="#a3a3a3" shape=cylinder]
|
||||
app_fe [label="Frontend"]
|
||||
app_be [label="Backend"]
|
||||
app_db [label="Database" shape=cylinder]
|
||||
}
|
||||
|
||||
// Connections
|
||||
hub -> veins [label="routes" color="#b91c1c"]
|
||||
hub -> books [label="routes" color="#15803d"]
|
||||
hub -> tools [label="routes" color="#1d4ed8"]
|
||||
hub -> veins [class="artery" label="routes"]
|
||||
hub -> books [class="atlas" label="routes"]
|
||||
hub -> tools [class="station" label="routes"]
|
||||
|
||||
veins -> jira [label="API"]
|
||||
veins -> google [label="OAuth"]
|
||||
@@ -93,5 +83,5 @@ digraph system_overview {
|
||||
monitors -> app_db [label="browse" style=dashed]
|
||||
|
||||
// Sidebar injection
|
||||
hub -> app_fe [label="sidebar\ninjection" color="#d4a574" style=dashed]
|
||||
hub -> app_fe [class="accent" label="sidebar\ninjection" style=dashed]
|
||||
}
|
||||
|
||||
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>
|
||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-368.25 1149,-368.25 1149,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="572.5" y="-346.95" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint — System Overview</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<g id="clust1" class="cluster accent">
|
||||
<title>cluster_core</title>
|
||||
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="538,-241.25 538,-331 660,-331 660,-241.25 538,-241.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="599" y="-313.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint Hub</text>
|
||||
</g>
|
||||
<g id="clust2" class="cluster">
|
||||
<g id="clust2" class="cluster artery">
|
||||
<title>cluster_artery</title>
|
||||
<polygon fill="#0a0a0a" stroke="#b91c1c" stroke-dasharray="5,2" points="8,-8 8,-212 382,-212 382,-8 8,-8"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="195" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#fca5a5">Artery — Todo lo vital</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<g id="clust3" class="cluster atlas">
|
||||
<title>cluster_atlas</title>
|
||||
<polygon fill="#0a0a0a" stroke="#15803d" stroke-dasharray="5,2" points="390,-121.25 390,-212 665,-212 665,-121.25 390,-121.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="527.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#86efac">Atlas — Documentacion accionable</text>
|
||||
</g>
|
||||
<g id="clust4" class="cluster">
|
||||
<g id="clust4" class="cluster station">
|
||||
<title>cluster_station</title>
|
||||
<polygon fill="#0a0a0a" stroke="#1d4ed8" stroke-dasharray="5,2" points="673,-121.25 673,-212 1062,-212 1062,-121.25 673,-121.25"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="867.5" y="-194.7" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#93c5fd">Station — Centro de control</text>
|
||||
@@ -56,11 +56,11 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="108" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">stateless connectors</text>
|
||||
</g>
|
||||
<!-- hub->veins -->
|
||||
<g id="edge1" class="edge">
|
||||
<g id="edge1" class="edge artery">
|
||||
<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"/>
|
||||
<polygon fill="#b91c1c" stroke="#b91c1c" points="152.32,-180.52 141.98,-178.24 148.65,-186.47 152.32,-180.52"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="284.95" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="284.95" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#fca5a5">routes</text>
|
||||
</g>
|
||||
<!-- books -->
|
||||
<g id="node5" class="node">
|
||||
@@ -70,11 +70,11 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="468" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">documentation</text>
|
||||
</g>
|
||||
<!-- hub->books -->
|
||||
<g id="edge2" class="edge">
|
||||
<g id="edge2" class="edge atlas">
|
||||
<title>hub->books</title>
|
||||
<path fill="none" stroke="#15803d" d="M572.81,-249C552.24,-230.55 523.47,-204.75 501.27,-184.84"/>
|
||||
<polygon fill="#15803d" stroke="#15803d" points="503.73,-182.34 493.94,-178.27 499.05,-187.55 503.73,-182.34"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="567.14" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="567.14" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#86efac">routes</text>
|
||||
</g>
|
||||
<!-- tools -->
|
||||
<g id="node7" class="node">
|
||||
@@ -84,25 +84,25 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="802" y="-143.55" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">tester · datagen · modelgen</text>
|
||||
</g>
|
||||
<!-- hub->tools -->
|
||||
<g id="edge3" class="edge">
|
||||
<g id="edge3" class="edge station">
|
||||
<title>hub->tools</title>
|
||||
<path fill="none" stroke="#1d4ed8" d="M639.59,-249C672.13,-230.17 717.93,-203.66 752.6,-183.59"/>
|
||||
<polygon fill="#1d4ed8" stroke="#1d4ed8" points="754.3,-186.65 761.2,-178.61 750.8,-180.59 754.3,-186.65"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="702.6" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">routes</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="702.6" y="-222.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#93c5fd">routes</text>
|
||||
</g>
|
||||
<!-- app_fe -->
|
||||
<g id="node12" class="node">
|
||||
<title>app_fe</title>
|
||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="1089" cy="-40.75" rx="39.9" ry="18"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="1089" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Frontend</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="1089" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text>
|
||||
</g>
|
||||
<!-- hub->app_fe -->
|
||||
<g id="edge10" class="edge">
|
||||
<g id="edge4" class="edge accent">
|
||||
<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"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="1098.04,-69.95 1092.97,-60.65 1091.14,-71.08 1098.04,-69.95"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-156.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">sidebar</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-145.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">injection</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-156.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">sidebar</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="1116.94" y="-145.45" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#d4a574">injection</text>
|
||||
</g>
|
||||
<!-- pulses -->
|
||||
<g id="node4" class="node">
|
||||
@@ -112,7 +112,7 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="108" y="-30.3" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">composed flows</text>
|
||||
</g>
|
||||
<!-- veins->pulses -->
|
||||
<g id="edge7" class="edge">
|
||||
<g id="edge5" class="edge">
|
||||
<title>veins->pulses</title>
|
||||
<path fill="none" stroke="#666666" d="M108,-128.86C108,-113.7 108,-93.88 108,-76.98"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="111.5,-77.35 108,-67.35 104.5,-77.35 111.5,-77.35"/>
|
||||
@@ -122,10 +122,10 @@
|
||||
<g id="node9" class="node">
|
||||
<title>jira</title>
|
||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="425" cy="-40.75" rx="27" ry="18"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="425" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Jira</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="425" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Jira</text>
|
||||
</g>
|
||||
<!-- veins->jira -->
|
||||
<g id="edge4" class="edge">
|
||||
<g id="edge6" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="413.44,-69.58 415.88,-59.27 407.54,-65.8 413.44,-69.58"/>
|
||||
@@ -135,10 +135,10 @@
|
||||
<g id="node10" class="node">
|
||||
<title>google</title>
|
||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="504" cy="-40.75" rx="33.82" ry="18"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="504" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Google</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="504" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Google</text>
|
||||
</g>
|
||||
<!-- veins->google -->
|
||||
<g id="edge5" class="edge">
|
||||
<g id="edge7" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="489.88,-69.43 493,-59.31 484.25,-65.28 489.88,-69.43"/>
|
||||
@@ -148,10 +148,10 @@
|
||||
<g id="node11" class="node">
|
||||
<title>slack</title>
|
||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="584" cy="-40.75" rx="27.74" ry="18"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="584" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Slack</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="584" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Slack</text>
|
||||
</g>
|
||||
<!-- veins->slack -->
|
||||
<g id="edge6" class="edge">
|
||||
<g id="edge8" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="572.65,-69.66 574.98,-59.33 566.72,-65.96 572.65,-69.66"/>
|
||||
@@ -175,10 +175,10 @@
|
||||
<g id="node13" class="node">
|
||||
<title>app_be</title>
|
||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="906" cy="-40.75" rx="38.96" ry="18"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="906" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Backend</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="906" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend</text>
|
||||
</g>
|
||||
<!-- tools->app_be -->
|
||||
<g id="edge8" class="edge">
|
||||
<g id="edge9" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="885.72,-68.57 889.99,-58.88 880.6,-63.79 885.72,-68.57"/>
|
||||
@@ -196,10 +196,10 @@
|
||||
<title>app_db</title>
|
||||
<path fill="#1a1a1a" stroke="#333333" d="M1031.25,-55.48C1031.25,-57.28 1015.9,-58.75 997,-58.75 978.1,-58.75 962.75,-57.28 962.75,-55.48 962.75,-55.48 962.75,-26.02 962.75,-26.02 962.75,-24.22 978.1,-22.75 997,-22.75 1015.9,-22.75 1031.25,-24.22 1031.25,-26.02 1031.25,-26.02 1031.25,-55.48 1031.25,-55.48"/>
|
||||
<path fill="none" stroke="#333333" d="M1031.25,-55.48C1031.25,-53.67 1015.9,-52.2 997,-52.2 978.1,-52.2 962.75,-53.67 962.75,-55.48"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="997" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#a3a3a3">Database</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="997" y="-37.05" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Database</text>
|
||||
</g>
|
||||
<!-- monitors->app_db -->
|
||||
<g id="edge9" class="edge">
|
||||
<g id="edge10" class="edge">
|
||||
<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"/>
|
||||
<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 {
|
||||
bgcolor="#0a0a0a"
|
||||
rankdir=LR
|
||||
fontname="Helvetica"
|
||||
node [fontname="Helvetica" fontsize=11 style=filled color="#333" fontcolor="#e5e5e5" shape=box]
|
||||
edge [fontname="Helvetica" fontsize=9 fontcolor="#a3a3a3" color="#666"]
|
||||
node [fontname="Helvetica" fontsize=11 style=filled shape=box]
|
||||
edge [fontname="Helvetica" fontsize=9]
|
||||
|
||||
label="Sidebar Injection — How Wrapping Works"
|
||||
labelloc=t
|
||||
fontsize=14
|
||||
fontcolor="#d4a574"
|
||||
|
||||
browser [label="Browser" fillcolor="#1a1a1a" shape=oval]
|
||||
browser [label="Browser" shape=oval]
|
||||
|
||||
subgraph cluster_nginx {
|
||||
label="Nginx (reverse proxy)"
|
||||
style=dashed
|
||||
color="#d4a574"
|
||||
fontcolor="#d4a574"
|
||||
class="accent"
|
||||
|
||||
proxy [label="proxy_pass\n+\nsub_filter\ninjects sidebar" fillcolor="#1a1a1a" shape=component]
|
||||
proxy [label="proxy_pass\n+\nsub_filter\ninjects sidebar" shape=component]
|
||||
}
|
||||
|
||||
subgraph cluster_app {
|
||||
label="Managed App"
|
||||
style=dashed
|
||||
color="#333"
|
||||
fontcolor="#666"
|
||||
|
||||
frontend [label="Frontend\n(React/Next/Vue)" fillcolor="#1a1a1a"]
|
||||
backend [label="Backend API" fillcolor="#1a1a1a"]
|
||||
frontend [label="Frontend\n(React/Next/Vue)"]
|
||||
backend [label="Backend API"]
|
||||
}
|
||||
|
||||
subgraph cluster_spr {
|
||||
label="Soleprint"
|
||||
style=dashed
|
||||
color="#d4a574"
|
||||
fontcolor="#d4a574"
|
||||
class="accent"
|
||||
|
||||
sidebar_css [label="sidebar.css" fillcolor="#1a1a1a"]
|
||||
sidebar_js [label="sidebar.js" fillcolor="#1a1a1a"]
|
||||
hub [label="Hub API\n/api/sidebar/config" fillcolor="#1a1a1a"]
|
||||
sidebar_css [label="sidebar.css"]
|
||||
sidebar_js [label="sidebar.js"]
|
||||
hub [label="Hub API\n/api/sidebar/config"]
|
||||
}
|
||||
|
||||
browser -> proxy [label="myroom.spr.local.ar"]
|
||||
@@ -47,8 +41,8 @@ digraph wrapping {
|
||||
proxy -> hub [label="/spr/ → soleprint"]
|
||||
|
||||
// The injection
|
||||
proxy -> sidebar_css [label="injects into </head>" color="#d4a574" style=dashed]
|
||||
proxy -> sidebar_js [color="#d4a574" style=dashed]
|
||||
proxy -> sidebar_css [class="accent" label="injects into </head>" style=dashed]
|
||||
proxy -> sidebar_js [class="accent" style=dashed]
|
||||
|
||||
sidebar_js -> hub [label="loads config" color="#d4a574"]
|
||||
sidebar_js -> hub [class="accent" label="loads config"]
|
||||
}
|
||||
|
||||
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>
|
||||
<polygon fill="#0a0a0a" stroke="none" points="-4,4 -4,-325.41 819.55,-325.41 819.55,4 -4,4"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="407.78" y="-304.11" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Sidebar Injection — How Wrapping Works</text>
|
||||
<g id="clust1" class="cluster">
|
||||
<g id="clust1" class="cluster accent">
|
||||
<title>cluster_nginx</title>
|
||||
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="193.3,-25.16 193.3,-128.16 360.05,-128.16 360.05,-25.16 193.3,-25.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="276.68" y="-110.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Nginx (reverse proxy)</text>
|
||||
@@ -20,19 +20,13 @@
|
||||
<polygon fill="#0a0a0a" stroke="#333333" stroke-dasharray="5,2" points="473.05,-157.16 473.05,-288.16 599.55,-288.16 599.55,-157.16 473.05,-157.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-270.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#666666">Managed App</text>
|
||||
</g>
|
||||
<g id="clust3" class="cluster">
|
||||
<g id="clust3" class="cluster accent">
|
||||
<title>cluster_spr</title>
|
||||
<polygon fill="#0a0a0a" stroke="#d4a574" stroke-dasharray="5,2" points="489.55,-18.16 489.55,-149.16 807.55,-149.16 807.55,-18.16 489.55,-18.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="648.55" y="-131.86" font-family="Helvetica,sans-Serif" font-size="14.00" fill="#d4a574">Soleprint</text>
|
||||
</g>
|
||||
<!-- browser -->
|
||||
<g id="node1" class="node">
|
||||
<title>browser</title>
|
||||
<ellipse fill="#1a1a1a" stroke="#333333" cx="38.03" cy="-64.16" rx="38.03" ry="18"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="38.03" y="-60.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Browser</text>
|
||||
</g>
|
||||
<!-- proxy -->
|
||||
<g id="node2" class="node">
|
||||
<g id="node1" class="node">
|
||||
<title>proxy</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="324.3,-95.16 228.05,-95.16 228.05,-91.16 224.05,-91.16 224.05,-87.16 228.05,-87.16 228.05,-41.16 224.05,-41.16 224.05,-37.16 228.05,-37.16 228.05,-33.16 324.3,-33.16 324.3,-95.16"/>
|
||||
<polyline fill="none" stroke="#333333" points="228.05,-91.16 232.05,-91.16 232.05,-87.16 228.05,-87.16"/>
|
||||
@@ -42,15 +36,8 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="276.18" y="-53.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sub_filter</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="276.18" y="-40.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">injects sidebar</text>
|
||||
</g>
|
||||
<!-- browser->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 -->
|
||||
<g id="node3" class="node">
|
||||
<g id="node2" class="node">
|
||||
<title>frontend</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="591.55,-201.16 481.05,-201.16 481.05,-165.16 591.55,-165.16 591.55,-201.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-186.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Frontend</text>
|
||||
@@ -64,56 +51,69 @@
|
||||
<text xml:space="preserve" text-anchor="middle" x="416.55" y="-150.7" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/ → app</text>
|
||||
</g>
|
||||
<!-- sidebar_css -->
|
||||
<g id="node5" class="node">
|
||||
<g id="node4" class="node">
|
||||
<title>sidebar_css</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="575.05,-116.16 497.55,-116.16 497.55,-80.16 575.05,-80.16 575.05,-116.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-94.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.css</text>
|
||||
</g>
|
||||
<!-- proxy->sidebar_css -->
|
||||
<g id="edge4" class="edge">
|
||||
<g id="edge3" class="edge accent">
|
||||
<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"/>
|
||||
<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>
|
||||
<!-- sidebar_js -->
|
||||
<g id="node6" class="node">
|
||||
<g id="node5" class="node">
|
||||
<title>sidebar_js</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="570.55,-62.16 502.05,-62.16 502.05,-26.16 570.55,-26.16 570.55,-62.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-40.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">sidebar.js</text>
|
||||
</g>
|
||||
<!-- proxy->sidebar_js -->
|
||||
<g id="edge5" class="edge">
|
||||
<g id="edge4" class="edge accent">
|
||||
<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"/>
|
||||
<polygon fill="#d4a574" stroke="#d4a574" points="490.6,-51.14 500.3,-46.87 490.06,-44.16 490.6,-51.14"/>
|
||||
</g>
|
||||
<!-- hub -->
|
||||
<g id="node7" class="node">
|
||||
<g id="node6" class="node">
|
||||
<title>hub</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="799.55,-62.16 682.3,-62.16 682.3,-26.16 799.55,-26.16 799.55,-62.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="740.93" y="-47.21" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Hub API</text>
|
||||
<text xml:space="preserve" text-anchor="middle" x="740.93" y="-33.71" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">/api/sidebar/config</text>
|
||||
</g>
|
||||
<!-- proxy->hub -->
|
||||
<g id="edge3" class="edge">
|
||||
<g id="edge5" class="edge">
|
||||
<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"/>
|
||||
<polygon fill="#666666" stroke="#666666" points="673.59,-25.84 684.17,-25.31 675.57,-19.12 673.59,-25.84"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-8.61" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#a3a3a3">/spr/ → soleprint</text>
|
||||
</g>
|
||||
<!-- backend -->
|
||||
<g id="node4" class="node">
|
||||
<g id="node3" class="node">
|
||||
<title>backend</title>
|
||||
<polygon fill="#1a1a1a" stroke="#333333" points="578.05,-255.16 494.55,-255.16 494.55,-219.16 578.05,-219.16 578.05,-255.16"/>
|
||||
<text xml:space="preserve" text-anchor="middle" x="536.3" y="-233.46" font-family="Helvetica,sans-Serif" font-size="11.00" fill="#e5e5e5">Backend API</text>
|
||||
</g>
|
||||
<!-- sidebar_js->hub -->
|
||||
<g id="edge6" class="edge">
|
||||
<g id="edge6" class="edge accent">
|
||||
<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"/>
|
||||
<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>
|
||||
</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#",
|
||||
"title": "Pawprint Models",
|
||||
"title": "Soleprint Models",
|
||||
"description": "Platform-agnostic model definitions. Portable to TypeScript, Pydantic, Django, Prisma.",
|
||||
"definitions": {
|
||||
"Status": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="soleprint">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -14,7 +14,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
@@ -25,8 +25,8 @@
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
background: #b91c1c;
|
||||
color: var(--text);
|
||||
background: var(--system-accent);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
@@ -51,7 +51,7 @@
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
section {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 12px;
|
||||
@@ -59,23 +59,23 @@
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.composition {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #b91c1c;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--system-accent);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.composition h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.composition > p {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
@@ -83,20 +83,20 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.component {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #3f3f3f;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.component h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.component p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
.veins {
|
||||
display: grid;
|
||||
@@ -104,8 +104,8 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
.vein {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3f3f3f;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
@@ -113,16 +113,16 @@
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vein:hover {
|
||||
background: #2a2a2a;
|
||||
background: var(--border);
|
||||
}
|
||||
.vein.selected {
|
||||
border-color: #b91c1c;
|
||||
border-color: var(--system-accent);
|
||||
border-width: 2px;
|
||||
background: #1a1a1a;
|
||||
background: var(--surface);
|
||||
}
|
||||
.vein.active {
|
||||
background: #b91c1c;
|
||||
border-color: #b91c1c;
|
||||
background: var(--system-accent);
|
||||
border-color: var(--system-accent);
|
||||
}
|
||||
.vein.active h3 {
|
||||
color: white;
|
||||
@@ -139,12 +139,12 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.vein.disabled:hover {
|
||||
background: #1a1a1a;
|
||||
background: var(--surface);
|
||||
}
|
||||
.vein h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #e5e5e5;
|
||||
color: var(--text);
|
||||
}
|
||||
.endpoints {
|
||||
list-style: none;
|
||||
@@ -153,7 +153,7 @@
|
||||
}
|
||||
.endpoints li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #3f3f3f;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
@@ -164,18 +164,18 @@
|
||||
}
|
||||
.endpoints code {
|
||||
font-family: monospace;
|
||||
background: #1a1a1a;
|
||||
background: var(--surface);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.endpoints .desc {
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
code {
|
||||
background: #2a2a2a;
|
||||
color: #fca5a5;
|
||||
background: var(--border);
|
||||
color: var(--system-accent-text);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
@@ -210,41 +210,41 @@
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #e5e5e5;
|
||||
color: var(--text);
|
||||
}
|
||||
.api-form input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #3f3f3f;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
background: #1a1a1a;
|
||||
color: #e5e5e5;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.api-form input[type="text"]:focus,
|
||||
.api-form select:focus {
|
||||
outline: none;
|
||||
border-color: #b91c1c;
|
||||
border-color: var(--system-accent);
|
||||
}
|
||||
.api-form select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #3f3f3f;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: #1a1a1a;
|
||||
color: #e5e5e5;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-form select:disabled {
|
||||
background: #0a0a0a;
|
||||
color: #666;
|
||||
background: var(--bg);
|
||||
color: var(--dim);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.api-form input:disabled {
|
||||
background: #0a0a0a;
|
||||
color: #666;
|
||||
background: var(--bg);
|
||||
color: var(--dim);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.api-controls {
|
||||
@@ -254,7 +254,7 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.api-controls button {
|
||||
background: #b91c1c;
|
||||
background: var(--system-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
@@ -271,17 +271,17 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tab-button {
|
||||
background: #1a1a1a !important;
|
||||
border: 1px solid #3f3f3f !important;
|
||||
color: #e5e5e5 !important;
|
||||
background: var(--surface) !important;
|
||||
border: 1px solid var(--border-strong) !important;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background: #2a2a2a !important;
|
||||
background: var(--border) !important;
|
||||
}
|
||||
.tab-button.active {
|
||||
border-color: white !important;
|
||||
border-width: 2px !important;
|
||||
background: #b91c1c !important;
|
||||
background: var(--system-accent) !important;
|
||||
color: white !important;
|
||||
}
|
||||
.tab-button.active:hover {
|
||||
@@ -291,19 +291,19 @@
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
padding: 2rem;
|
||||
color: #e5e5e5;
|
||||
color: var(--text);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.epic-status.error {
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
color: #0a0a0a;
|
||||
color: var(--bg);
|
||||
}
|
||||
50% {
|
||||
color: #b91c1c;
|
||||
color: var(--system-accent);
|
||||
}
|
||||
}
|
||||
.api-controls label {
|
||||
@@ -312,7 +312,7 @@
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
color: #e5e5e5;
|
||||
color: var(--text);
|
||||
}
|
||||
.output-container {
|
||||
position: relative;
|
||||
@@ -324,8 +324,8 @@
|
||||
}
|
||||
|
||||
.output-area {
|
||||
background: #1a1a1a;
|
||||
color: #e5e5e5;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 1rem;
|
||||
padding-top: 2.5rem;
|
||||
border-radius: 8px;
|
||||
@@ -337,7 +337,7 @@
|
||||
position: relative;
|
||||
}
|
||||
.output-area.error {
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.output-area.scrollable {
|
||||
max-height: 1000px;
|
||||
@@ -353,7 +353,7 @@
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.attachments-container h3 {
|
||||
color: #fca5a5;
|
||||
color: var(--system-accent-text);
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -370,11 +370,31 @@
|
||||
}
|
||||
.attachment-label {
|
||||
font-size: 0.85rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--border-strong: #3d3d4a;
|
||||
--dim: #555568;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--system-accent: #d4a574;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Artery keeps its own colour under every theme. */
|
||||
:root { --system-accent: #b91c1c; --system-accent-text: #fca5a5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Flux capacitor -->
|
||||
@@ -393,8 +413,8 @@
|
||||
<circle cx="24" cy="20" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
<h1>Artery</h1>
|
||||
{% if pawprint_url %}<a
|
||||
href="{{ pawprint_url }}"
|
||||
{% if soleprint_url %}<a
|
||||
href="{{ soleprint_url }}"
|
||||
style="
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -913,7 +933,7 @@
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
{% if pawprint_url %}<a href="{{ pawprint_url }}">← Soleprint</a>{%
|
||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a>{%
|
||||
else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||
</footer>
|
||||
|
||||
@@ -1824,5 +1844,6 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
<script src="/theme.js" defer></script>
|
||||
</body>
|
||||
</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 pydantic import BaseModel
|
||||
|
||||
# Import datagen from ward/tools
|
||||
import sys
|
||||
from pathlib import Path
|
||||
ward_tools_path = Path(__file__).parent.parent.parent.parent.parent / "ward" / "tools"
|
||||
sys.path.insert(0, str(ward_tools_path))
|
||||
|
||||
from datagen.mercadopago import MercadoPagoDataGenerator
|
||||
|
||||
from ..core.config import settings
|
||||
from core.config import settings
|
||||
from datagen import MercadoPagoDataGenerator
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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.templating import Jinja2Templates
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from .api.routes import router
|
||||
from .core.config import settings
|
||||
# Absolute imports: a shunt is started from its own directory (`python run.py`),
|
||||
# not imported as a package, so relative imports have no parent to resolve.
|
||||
from api.routes import router
|
||||
from core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title="MercadoPago (MOCK)",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="soleprint">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -14,7 +14,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
@@ -25,7 +25,7 @@
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
color: var(--text);
|
||||
background: #163528;
|
||||
}
|
||||
header {
|
||||
@@ -59,7 +59,7 @@
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: #86efac;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.composition {
|
||||
background: #000000;
|
||||
@@ -70,12 +70,12 @@
|
||||
.composition h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #86efac;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.composition > p {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
@@ -83,7 +83,7 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.component {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
border: 1px solid #6b665e;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
@@ -91,12 +91,12 @@
|
||||
.component h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: #86efac;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.component p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
.books {
|
||||
list-style: none;
|
||||
@@ -114,7 +114,7 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
.books a {
|
||||
color: #86efac;
|
||||
color: var(--system-accent-text);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -138,7 +138,22 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--muted: #8888a0;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Atlas keeps its own colour under every theme. */
|
||||
:root { --system-accent: #15803d; --system-accent-text: #86efac; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Open book -->
|
||||
@@ -265,5 +280,6 @@
|
||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
||||
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||
</footer>
|
||||
</body>
|
||||
<script src="/theme.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Album - Documentation system.
|
||||
Atlas - Documentation system.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -11,7 +11,7 @@ from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
app = FastAPI(title="Album", version="0.1.0")
|
||||
app = FastAPI(title="Atlas", version="0.1.0")
|
||||
|
||||
BASE_DIR = Path(__file__).parent.resolve()
|
||||
BOOK_DIR = BASE_DIR / "book"
|
||||
@@ -25,24 +25,24 @@ templates = Jinja2Templates(directory=str(BASE_DIR))
|
||||
# Serve static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Pawprint URL for data fetching
|
||||
PAWPRINT_URL = os.getenv("PAWPRINT_URL", "http://localhost:12000")
|
||||
# The soleprint hub this atlas reads its data from.
|
||||
SOLEPRINT_URL = os.getenv("SOLEPRINT_URL", "http://localhost:12000")
|
||||
|
||||
|
||||
def get_data():
|
||||
"""Fetch data from pawprint hub."""
|
||||
"""Fetch data from the soleprint hub."""
|
||||
try:
|
||||
resp = httpx.get(f"{PAWPRINT_URL}/api/data/album", timeout=5.0)
|
||||
resp = httpx.get(f"{SOLEPRINT_URL}/api/data/atlas", timeout=5.0)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch data from pawprint: {e}")
|
||||
return {"templates": [], "larders": [], "books": []}
|
||||
print(f"Failed to fetch data from soleprint: {e}")
|
||||
return {"templates": [], "depots": [], "books": []}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "album"}
|
||||
return {"status": "ok", "service": "atlas"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -52,7 +52,7 @@ def index(request: Request):
|
||||
"index.html",
|
||||
{
|
||||
"request": request,
|
||||
"pawprint_url": os.getenv("PAWPRINT_EXTERNAL_URL", PAWPRINT_URL),
|
||||
"soleprint_url": os.getenv("SOLEPRINT_EXTERNAL_URL", SOLEPRINT_URL),
|
||||
**data,
|
||||
},
|
||||
)
|
||||
@@ -60,7 +60,7 @@ def index(request: Request):
|
||||
|
||||
@app.get("/api/data")
|
||||
def api_data():
|
||||
"""API endpoint for frontend data (proxied from pawprint)."""
|
||||
"""API endpoint for frontend data (proxied from soleprint)."""
|
||||
return get_data()
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ def feature_form_samples_template():
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Feature Form Template · Album</title>
|
||||
<title>Feature Form Template · Atlas</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
@@ -229,7 +229,7 @@ def feature_form_samples_template():
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Album</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
|
||||
<a href="/">Atlas</a> / <a href="/book/feature-form-samples/">Feature Form Samples</a> / Template
|
||||
</div>
|
||||
<h1>Feature Form Template</h1>
|
||||
<div class="meta">
|
||||
@@ -244,7 +244,7 @@ def feature_form_samples_template():
|
||||
<div class="form-body">
|
||||
<div class="field">
|
||||
<label class="field-label">Tipo de Usuario</label>
|
||||
<div class="field-value">[Dueno de mascota / Veterinario / Admin]</div>
|
||||
<div class="field-value">[Tipo A / Tipo B / Admin]</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Punto de Entrada</label>
|
||||
@@ -296,25 +296,25 @@ def feature_form_samples_template():
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
@app.get("/book/feature-form-samples/larder/", response_class=HTMLResponse)
|
||||
@app.get("/book/feature-form-samples/larder", response_class=HTMLResponse)
|
||||
def feature_form_samples_larder():
|
||||
"""Browse the larder (actual data)"""
|
||||
@app.get("/book/feature-form-samples/depot/", response_class=HTMLResponse)
|
||||
@app.get("/book/feature-form-samples/depot", response_class=HTMLResponse)
|
||||
def feature_form_samples_depot():
|
||||
"""Browse the depot (actual data)"""
|
||||
html_file = BOOK_DIR / "feature-form-samples" / "index.html"
|
||||
if html_file.exists():
|
||||
return HTMLResponse(html_file.read_text())
|
||||
return HTMLResponse("<h1>Larder index not found</h1>", status_code=404)
|
||||
return HTMLResponse("<h1>Depot index not found</h1>", status_code=404)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/book/feature-form-samples/larder/{user_type}/{filename}",
|
||||
"/book/feature-form-samples/depot/{user_type}/{filename}",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
def feature_form_samples_detail(request: Request, user_type: str, filename: str):
|
||||
"""View a specific feature form"""
|
||||
# Look in the larder subfolder (feature-form)
|
||||
larder_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
|
||||
file_path = larder_dir / user_type / filename
|
||||
# Look in the depot subfolder (feature-form)
|
||||
depot_dir = BOOK_DIR / "feature-form-samples" / "feature-form"
|
||||
file_path = depot_dir / user_type / filename
|
||||
if not file_path.exists():
|
||||
return HTMLResponse("<h1>Not found</h1>", status_code=404)
|
||||
|
||||
|
||||
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,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./style.css": "./dist/style.css",
|
||||
"./theme.css": "./src/theme.css",
|
||||
"./tokens.css": "./src/tokens.css",
|
||||
"./base.css": "./src/base.css",
|
||||
"./dist/*": "./dist/*",
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
|
||||
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
|
||||
|
||||
// Theme (tokens + base layer). Imported here so the visual identity is part of
|
||||
// the bundle rather than something each consumer must remember to wire up —
|
||||
// every component below styles itself with the variables it defines.
|
||||
import './theme.css'
|
||||
|
||||
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
|
||||
export { SSEDataSource } from './datasources/SSEDataSource'
|
||||
export { StaticDataSource } from './datasources/StaticDataSource'
|
||||
@@ -14,6 +20,7 @@ export { default as ResizeHandle } from './components/ResizeHandle.vue'
|
||||
export { default as SplitPane } from './components/SplitPane.vue'
|
||||
export { default as ParameterEditor } from './components/ParameterEditor.vue'
|
||||
export type { ConfigField } from './components/ParameterEditor.vue'
|
||||
export { default as VideoPlayer } from './components/VideoPlayer.vue'
|
||||
|
||||
// Renderers
|
||||
export { default as LogRenderer } from './renderers/LogRenderer.vue'
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="soleprint">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -14,7 +14,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
@@ -25,8 +25,8 @@
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
background: #0a0a0a;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* Sidebar styles */
|
||||
.sidebar {
|
||||
@@ -35,8 +35,8 @@
|
||||
left: 0;
|
||||
width: 60px;
|
||||
height: 100vh;
|
||||
background: #1a1a1a;
|
||||
border-right: 1px solid #333;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -52,16 +52,16 @@
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
a.sidebar-item:hover {
|
||||
background: #333;
|
||||
background: var(--border);
|
||||
color: white;
|
||||
}
|
||||
.sidebar-item.active {
|
||||
background: #d4a574;
|
||||
color: #0a0a0a;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
.sidebar-item svg {
|
||||
width: 24px;
|
||||
@@ -70,7 +70,7 @@
|
||||
.sidebar-divider {
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background: #333;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.sidebar-icon {
|
||||
@@ -89,7 +89,7 @@
|
||||
.sidebar-item .tooltip {
|
||||
position: absolute;
|
||||
left: 70px;
|
||||
background: #333;
|
||||
background: var(--border);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
@@ -121,18 +121,18 @@
|
||||
color: white;
|
||||
}
|
||||
.tagline {
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #333;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
.mission {
|
||||
background: #1a1a1a;
|
||||
border-left: 3px solid #d4a574;
|
||||
background: var(--surface);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 1rem 1.5rem;
|
||||
margin: 2rem 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: #d4a574;
|
||||
color: var(--accent);
|
||||
}
|
||||
.systems {
|
||||
display: grid;
|
||||
@@ -171,11 +171,11 @@
|
||||
.system-info p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.artery {
|
||||
background: #1a1a1a;
|
||||
background: var(--surface);
|
||||
border: 1px solid #b91c1c;
|
||||
}
|
||||
.artery h2 {
|
||||
@@ -186,7 +186,7 @@
|
||||
}
|
||||
|
||||
.atlas {
|
||||
background: #1a1a1a;
|
||||
background: var(--surface);
|
||||
border: 1px solid #15803d;
|
||||
}
|
||||
.atlas h2 {
|
||||
@@ -197,7 +197,7 @@
|
||||
}
|
||||
|
||||
.station {
|
||||
background: #1a1a1a;
|
||||
background: var(--surface);
|
||||
border: 1px solid #1d4ed8;
|
||||
}
|
||||
.station h2 {
|
||||
@@ -210,9 +210,9 @@
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #333;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.showcase-container {
|
||||
@@ -224,8 +224,8 @@
|
||||
}
|
||||
.showcase-link {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #d4a574, #b8956a);
|
||||
color: #0a0a0a;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-dim));
|
||||
color: var(--bg);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
@@ -237,19 +237,34 @@
|
||||
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
|
||||
}
|
||||
.showcase-hint {
|
||||
color: #666;
|
||||
color: var(--dim);
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.showcase-hint:hover {
|
||||
color: #d4a574;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
{% if managed %}
|
||||
<link rel="stylesheet" href="/sidebar.css">
|
||||
<script src="/sidebar.js"></script>
|
||||
{% endif %}
|
||||
</head>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--accent: #d4a574;
|
||||
--accent-dim: #b8956a;
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--dim: #555568;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
</head>
|
||||
<body{% if managed %} class="has-sidebar"{% endif %}>
|
||||
|
||||
<header>
|
||||
@@ -428,5 +443,6 @@
|
||||
</div>
|
||||
|
||||
<footer>soleprint</footer>
|
||||
</body>
|
||||
<script src="/theme.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,12 @@ pydantic>=2.5.0
|
||||
pydantic-settings>=2.0.0
|
||||
httpx>=0.25.0
|
||||
jinja2>=3.1.0
|
||||
# YAML: OpenAPI specs for shuntgen, and compose fragments for cabinets.
|
||||
# modelgen keeps this optional (it imports it lazily) so the pip package stays
|
||||
# dependency-free; the server always has it.
|
||||
pyyaml>=6.0
|
||||
# Multipart form parsing — file uploads in shuntgen's UI.
|
||||
python-multipart>=0.0.9
|
||||
|
||||
# Database (databrowse)
|
||||
sqlalchemy>=2.0.0
|
||||
|
||||
166
soleprint/run.py
@@ -249,19 +249,41 @@ def load_config() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
class SafeDict(dict):
|
||||
"""A dict whose missing keys read as empty rather than exploding a template.
|
||||
|
||||
The landing pages ask for two levels at once — `components.composed.title`.
|
||||
Jinja tolerates one missing level (it returns Undefined, and `or 'Desk'`
|
||||
catches it) but not two: attribute access on Undefined raises, and the
|
||||
whole page 500s.
|
||||
|
||||
That is not hypothetical. cfg/config.json is written into a room by
|
||||
build.py, so it does not exist in the source tree at all, and running
|
||||
`python run.py` from soleprint/ — the documented way to develop — took
|
||||
/station/ down every time.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
# The dict's own contents win; only genuinely absent keys become empty.
|
||||
if name in self:
|
||||
value = self[name]
|
||||
return SafeDict(value) if isinstance(value, dict) else value
|
||||
return SafeDict()
|
||||
|
||||
|
||||
def get_system_config(system_key: str) -> dict:
|
||||
"""Get system configuration by key (data_flow, documentation, execution)."""
|
||||
config = load_config()
|
||||
for system in config.get("systems", []):
|
||||
if system.get("key") == system_key:
|
||||
return system
|
||||
return {}
|
||||
return SafeDict(system)
|
||||
return SafeDict()
|
||||
|
||||
|
||||
def get_components(system_key: str) -> dict:
|
||||
"""Get component definitions for a system."""
|
||||
config = load_config()
|
||||
return config.get("components", {}).get(system_key, {})
|
||||
return SafeDict(config.get("components", {}).get(system_key, {}))
|
||||
|
||||
|
||||
def load_data(filename: str) -> list[dict]:
|
||||
@@ -461,6 +483,41 @@ def atlas_route(path: str):
|
||||
# === Station ===
|
||||
|
||||
|
||||
def load_station_cabinets() -> list[dict]:
|
||||
"""The dependency containers this room switched on.
|
||||
|
||||
Same two-step as artery's shunts: what the room declared, else what is on
|
||||
disk. The room's list is the honest answer — the catalog holds every
|
||||
cabinet that could be used, not the ones that were.
|
||||
"""
|
||||
declared = load_data("cabinets.json")
|
||||
catalog = SPR_ROOT / "station" / "cabinets"
|
||||
|
||||
if declared:
|
||||
cabinets = declared
|
||||
else:
|
||||
cabinets = [
|
||||
{"name": path.name}
|
||||
for path in sorted(catalog.iterdir())
|
||||
if path.is_dir() and not path.name.startswith(("_", "."))
|
||||
] if catalog.exists() else []
|
||||
|
||||
for cabinet in cabinets:
|
||||
name = cabinet.get("name", "")
|
||||
definition_path = catalog / name / "cabinet.json"
|
||||
if definition_path.exists():
|
||||
try:
|
||||
definition = json.loads(definition_path.read_text())
|
||||
for key in ("title", "description", "image", "rig_addon"):
|
||||
cabinet.setdefault(key, definition.get(key))
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
cabinet.setdefault("slug", name)
|
||||
cabinet.setdefault("title", name.replace("-", " ").title())
|
||||
cabinet.setdefault("status", "declared" if declared else "available")
|
||||
return cabinets
|
||||
|
||||
|
||||
@app.get("/station", response_class=HTMLResponse)
|
||||
@app.get("/station/", response_class=HTMLResponse)
|
||||
def station_index(request: Request):
|
||||
@@ -486,6 +543,7 @@ def station_index(request: Request):
|
||||
d["slug"] = d["name"]
|
||||
d["title"] = d["name"].replace("-", " ").title()
|
||||
d["status"] = "ready"
|
||||
cabinets = load_station_cabinets()
|
||||
from jinja2 import Template
|
||||
|
||||
template = Template(html_path.read_text())
|
||||
@@ -497,6 +555,7 @@ def station_index(request: Request):
|
||||
tools=tools,
|
||||
monitors=monitors,
|
||||
desks=desks,
|
||||
cabinets=cabinets,
|
||||
soleprint_url="/",
|
||||
)
|
||||
)
|
||||
@@ -512,24 +571,18 @@ def station_index(request: Request):
|
||||
)
|
||||
|
||||
|
||||
# Mount station tool routers
|
||||
try:
|
||||
from station.tools.tester.api import router as tester_router
|
||||
app.include_router(tester_router, prefix="/station")
|
||||
except ImportError as e:
|
||||
print(f"Warning: Could not load tester router: {e}")
|
||||
|
||||
try:
|
||||
from station.tools.graphgen.api import router as graphgen_router
|
||||
app.include_router(graphgen_router, prefix="/station")
|
||||
except ImportError as e:
|
||||
print(f"Warning: Could not load graphgen router: {e}")
|
||||
|
||||
try:
|
||||
from station.tools.datagen.api import router as datagen_router
|
||||
app.include_router(datagen_router, prefix="/station")
|
||||
except ImportError as e:
|
||||
print(f"Warning: Could not load datagen router: {e}")
|
||||
# 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:
|
||||
_module = importlib.import_module(f"station.tools.{_tool}.api")
|
||||
app.include_router(_module.router, prefix="/station")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load {_tool} router: {e}")
|
||||
|
||||
|
||||
@app.get("/station/{path:path}")
|
||||
@@ -541,6 +594,77 @@ def station_route(path: str):
|
||||
# === Sidebar Wrapper (served at /spr/* when proxied) ===
|
||||
|
||||
|
||||
# === Theme ===
|
||||
#
|
||||
# One stylesheet for every page, served the same way the sidebar is. tokens.css
|
||||
# declares the variables and both theme files ship in the same response, so a
|
||||
# page can switch themes without a second request and without FOUC.
|
||||
|
||||
|
||||
def available_themes() -> list[str]:
|
||||
"""Theme names, from the files themselves — adding one is adding a file."""
|
||||
theme_dir = SPR_ROOT / "common" / "theme" / "themes"
|
||||
if not theme_dir.exists():
|
||||
return ["soleprint"]
|
||||
names = sorted(p.stem for p in theme_dir.glob("*.css"))
|
||||
# Default first, so a consumer taking names[0] gets the sensible one.
|
||||
return sorted(names, key=lambda n: (n != "soleprint", n))
|
||||
|
||||
|
||||
def get_default_theme() -> str:
|
||||
"""The theme a page is served in, before the browser has an opinion."""
|
||||
framework = load_config().get("framework", {})
|
||||
theme = framework.get("theme", "soleprint")
|
||||
return theme if theme in available_themes() else "soleprint"
|
||||
|
||||
|
||||
@app.get("/theme.css")
|
||||
def theme_css():
|
||||
"""Serve the theme contract plus every theme, concatenated."""
|
||||
from fastapi.responses import Response
|
||||
|
||||
theme_dir = SPR_ROOT / "common" / "theme"
|
||||
parts = []
|
||||
|
||||
tokens = theme_dir / "tokens.css"
|
||||
if tokens.exists():
|
||||
parts.append(tokens.read_text())
|
||||
|
||||
# Sorted so the response is byte-stable and cacheable; the theme files are
|
||||
# scoped to [data-theme] selectors, so their order carries no meaning.
|
||||
for sheet in sorted((theme_dir / "themes").glob("*.css")):
|
||||
parts.append(f"\n/* ── {sheet.stem} ── */\n")
|
||||
parts.append(sheet.read_text())
|
||||
|
||||
if not parts:
|
||||
return Response(
|
||||
content="/* theme not found — is common/theme/ present? */",
|
||||
media_type="text/css",
|
||||
)
|
||||
return Response(content="".join(parts), media_type="text/css")
|
||||
|
||||
|
||||
@app.get("/theme.js")
|
||||
def theme_js():
|
||||
"""Serve the theme switcher."""
|
||||
from fastapi.responses import Response
|
||||
|
||||
js_path = SPR_ROOT / "common" / "theme" / "theme.js"
|
||||
if js_path.exists():
|
||||
return Response(
|
||||
content=js_path.read_text(), media_type="application/javascript"
|
||||
)
|
||||
return Response(
|
||||
content="/* theme.js not found */", media_type="application/javascript"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/theme")
|
||||
def theme_config():
|
||||
"""The server-side default, for pages that render their own <html> tag."""
|
||||
return {"theme": get_default_theme(), "themes": available_themes()}
|
||||
|
||||
|
||||
@app.get("/sidebar.css")
|
||||
def sidebar_css():
|
||||
"""Serve sidebar CSS for injection."""
|
||||
|
||||
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>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="soleprint">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
@@ -14,7 +14,7 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
}
|
||||
body {
|
||||
font-family:
|
||||
@@ -25,8 +25,8 @@
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
line-height: 1.6;
|
||||
color: #e5e5e5;
|
||||
background: #1d4ed8;
|
||||
color: var(--text);
|
||||
background: var(--system-accent);
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
@@ -51,7 +51,7 @@
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
section {
|
||||
background: #0a0a0a;
|
||||
background: var(--bg);
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 12px;
|
||||
@@ -59,23 +59,23 @@
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: #93c5fd;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.composition {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #1d4ed8;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--system-accent);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.composition h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #93c5fd;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.composition > p {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
.components {
|
||||
display: grid;
|
||||
@@ -83,20 +83,20 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.component {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #3f3f3f;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.component h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: #93c5fd;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.component p {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #a3a3a3;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tables {
|
||||
list-style: none;
|
||||
@@ -105,7 +105,7 @@
|
||||
}
|
||||
.tables li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #3f3f3f;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -116,32 +116,32 @@
|
||||
.tables .name {
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
color: #e5e5e5;
|
||||
color: var(--text);
|
||||
}
|
||||
.tables a.name:hover {
|
||||
color: #93c5fd;
|
||||
color: var(--system-accent-text);
|
||||
}
|
||||
.status {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
background: #2a2a2a;
|
||||
color: #a3a3a3;
|
||||
background: var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.health {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #3f3f3f;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #93c5fd;
|
||||
color: var(--system-accent-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.health:hover {
|
||||
background: #2a2a2a;
|
||||
background: var(--border);
|
||||
}
|
||||
footer {
|
||||
margin-top: 3rem;
|
||||
@@ -157,7 +157,26 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--border-strong: #3d3d4a;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--system-accent: #d4a574;
|
||||
--system-accent-text: #e0b98d;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
/* Station keeps its own colour under every theme. */
|
||||
:root { --system-accent: #1d4ed8; --system-accent-text: #93c5fd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header style="position: relative">
|
||||
<!-- Control station / monitor -->
|
||||
@@ -217,6 +236,20 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{{ (components.container.plural or 'cabinets')|title }}</h2>
|
||||
<ul class="tables">
|
||||
{% for cabinet in cabinets %}
|
||||
<li>
|
||||
<span class="name">{{ cabinet.title }}</span
|
||||
><span class="status">{{ cabinet.status }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><span class="name">--</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{{ (components.watcher.plural or 'monitors')|title }}</h2>
|
||||
<ul class="tables">
|
||||
@@ -263,5 +296,6 @@
|
||||
{% if soleprint_url %}<a href="{{ soleprint_url }}">← Soleprint</a
|
||||
>{% else %}<span class="disabled">← Soleprint</span>{% endif %}
|
||||
</footer>
|
||||
</body>
|
||||
<script src="/theme.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,26 +15,26 @@ This monitor provides at-a-glance views of the database grouped by test-relevant
|
||||
|
||||
## Architecture
|
||||
|
||||
Follows pawprint **book/larder pattern**:
|
||||
- **larder/** contains all data files (schema, views, scenarios)
|
||||
Follows the book/depot pattern:
|
||||
- **depot/** contains all data files (schema, views, scenarios)
|
||||
- **main.py** generates SQL queries from view definitions
|
||||
- Two modes: **SQL** (direct queries) and **API** (Django backend, placeholder)
|
||||
|
||||
### Key Concepts
|
||||
|
||||
**Schema** (`larder/schema.json`)
|
||||
**Schema** (`depot/schema.json`)
|
||||
- AMAR data model with SQL table mappings
|
||||
- Regular fields (from database columns)
|
||||
- Computed fields (SQL expressions)
|
||||
- Support for multiple graph generators
|
||||
|
||||
**Views** (`larder/views.json`)
|
||||
**Views** (`depot/views.json`)
|
||||
- Define what to display and how to group it
|
||||
- Each view targets an entity (User, PetOwner, Veterinarian, etc.)
|
||||
- Can group results (e.g., by role, by data state, by availability)
|
||||
- SQL is generated automatically from view configuration
|
||||
|
||||
**Scenarios** (`larder/scenarios.json`)
|
||||
**Scenarios** (`depot/scenarios.json`)
|
||||
- Test scenarios emerge from actual usage
|
||||
- Format defined, real scenarios added as needed
|
||||
- Links scenarios to specific views with filters
|
||||
@@ -49,21 +49,21 @@ Follows pawprint **book/larder pattern**:
|
||||
## Running Locally
|
||||
|
||||
```bash
|
||||
cd /home/mariano/wdir/ama/pawprint/ward/monitor/data_browse
|
||||
cd soleprint/station/monitors/databrowse
|
||||
python main.py
|
||||
# Opens on http://localhost:12020
|
||||
```
|
||||
|
||||
Or with uvicorn:
|
||||
```bash
|
||||
uvicorn ward.monitor.data_browse.main:app --port 12020 --reload
|
||||
uvicorn station.monitors.databrowse.main:app --port 12020 --reload
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Database connection (defaults to local dev)
|
||||
export NEST_NAME=local
|
||||
export ROOM_NAME=local
|
||||
export DB_HOST=localhost
|
||||
export DB_PORT=5433
|
||||
export DB_NAME=amarback
|
||||
@@ -85,7 +85,7 @@ GET /api/scenarios # Test scenarios (JSON)
|
||||
|
||||
## Adding New Views
|
||||
|
||||
Edit `larder/views.json`:
|
||||
Edit `depot/views.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -112,7 +112,7 @@ The SQL query is automatically generated from:
|
||||
|
||||
## Adding Computed Fields
|
||||
|
||||
Edit `larder/schema.json` in the entity definition:
|
||||
Edit `depot/schema.json` in the entity definition:
|
||||
|
||||
```json
|
||||
"computed": {
|
||||
@@ -127,7 +127,7 @@ Computed fields can be used in views just like regular fields.
|
||||
|
||||
## Adding Test Scenarios
|
||||
|
||||
As you identify test patterns, add them to `larder/scenarios.json`:
|
||||
As you identify test patterns, add them to `depot/scenarios.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -153,8 +153,8 @@ As you identify test patterns, add them to `larder/scenarios.json`:
|
||||
|
||||
```
|
||||
data_browse/
|
||||
├── larder/
|
||||
│ ├── .larder # Larder marker (book pattern)
|
||||
├── depot/
|
||||
│ ├── .depot # Depot marker (book pattern)
|
||||
│ ├── schema.json # AMAR data model with SQL mappings
|
||||
│ ├── views.json # View configurations
|
||||
│ └── scenarios.json # Test scenarios
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
|
||||
{% if views|length == 0 %}
|
||||
<div class="empty">
|
||||
No views configured. Add views to larder/views.json
|
||||
No views configured. Add views to depot/views.json
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-grid">
|
||||
@@ -289,7 +289,7 @@
|
||||
<div class="empty">
|
||||
No scenarios defined yet. Scenarios emerge from usage and
|
||||
conversations.
|
||||
<br />Add them to larder/scenarios.json as you identify test
|
||||
<br />Add them to depot/scenarios.json as you identify test
|
||||
patterns.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -1,164 +1,87 @@
|
||||
# Datagen - Test Data Generator
|
||||
# Datagen — Test Data Generator
|
||||
|
||||
Pluggable test data generators for various domain models and external APIs.
|
||||
Room-specific test data generators, discovered and served by the hub.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Generate realistic test data for Amar domain models
|
||||
- Generate mock API responses for external services (MercadoPago, etc.)
|
||||
- Can be plugged into any nest (test suites, mock veins, seeders)
|
||||
- Domain-agnostic and reusable
|
||||
The core ships the base class and the API only. **Generators themselves belong to a
|
||||
room** (`cfg/<room>/soleprint/station/tools/datagen/`) and are merged into the built
|
||||
instance — so no client's domain vocabulary lives here.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
datagen/
|
||||
├── __init__.py
|
||||
├── amar.py # Amar domain models (petowner, pet, cart, etc.)
|
||||
├── mercadopago.py # MercadoPago API responses
|
||||
└── README.md # This file
|
||||
├── base.py # BaseDataGenerator — the contract
|
||||
├── api.py # FastAPI router, mounted at /tools/datagen
|
||||
├── templates/
|
||||
│ └── index.html # browser UI
|
||||
└── README.md # this file
|
||||
```
|
||||
|
||||
## Usage
|
||||
## Writing a generator
|
||||
|
||||
### In Tests
|
||||
Subclass `BaseDataGenerator` and name each method after the model it generates. The
|
||||
method name *is* the model name — there is no registry to update.
|
||||
|
||||
```python
|
||||
from ward.tools.datagen.amar import AmarDataGenerator
|
||||
from faker import Faker
|
||||
|
||||
def test_petowner_creation():
|
||||
owner_data = AmarDataGenerator.petowner(address="Av. Corrientes 1234")
|
||||
assert owner_data["address"] == "Av. Corrientes 1234"
|
||||
```
|
||||
# Guarded so the file also runs on its own, outside a built instance —
|
||||
# see cfg/sample/soleprint/station/tools/datagen/fixture.py.
|
||||
try:
|
||||
from station.tools.datagen.base import BaseDataGenerator
|
||||
except ImportError:
|
||||
class BaseDataGenerator:
|
||||
pass
|
||||
|
||||
### In Mock Veins
|
||||
fake = Faker()
|
||||
|
||||
```python
|
||||
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
|
||||
|
||||
@router.post("/v1/preferences")
|
||||
async def create_preference(request: dict):
|
||||
# Generate mock response
|
||||
return MercadoPagoDataGenerator.preference(
|
||||
description=request["items"][0]["title"],
|
||||
total=request["items"][0]["unit_price"],
|
||||
)
|
||||
```
|
||||
|
||||
### In Seeders
|
||||
class MyRoomGenerator(BaseDataGenerator):
|
||||
def user(self, **kwargs):
|
||||
return {"id": fake.uuid4(), "name": fake.name(), **kwargs}
|
||||
|
||||
```python
|
||||
from ward.tools.datagen.amar import AmarDataGenerator
|
||||
|
||||
# Create 10 test pet owners
|
||||
for i in range(10):
|
||||
owner = AmarDataGenerator.petowner(is_guest=False)
|
||||
# Save to database...
|
||||
def product(self, category=None, **kwargs):
|
||||
return {"id": fake.uuid4(), "name": fake.word(), "category": category, **kwargs}
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Pluggable**: Can be used anywhere, not tied to specific frameworks
|
||||
2. **Realistic**: Generated data matches real-world patterns
|
||||
3. **Flexible**: Override any field via `**overrides` parameter
|
||||
4. **Domain-focused**: Each generator focuses on a specific domain
|
||||
5. **Stateless**: Pure functions, no global state
|
||||
Drop it in `cfg/<room>/soleprint/station/tools/datagen/<name>.py` and rebuild the room.
|
||||
|
||||
## Generators
|
||||
**Discovery rules** (`api.py:_load_generators`): every `*.py` in the tool directory is
|
||||
scanned except `base.py`, `api.py`, and anything starting with `_`. The first class whose
|
||||
name ends in `Generator` (and isn't `BaseDataGenerator`) is instantiated.
|
||||
|
||||
### AmarDataGenerator (amar.py)
|
||||
## What the base class gives you
|
||||
|
||||
Generates data for Amar platform:
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `generate(model, count=1, **kwargs)` | Call the matching method `count` times; raises `ValueError` listing available models if there's no match |
|
||||
| `available_models()` | Method names, minus the reserved ones — i.e. the models you support |
|
||||
| `schema()` | Optional override returning a graphgen-compatible schema; `None` by default |
|
||||
|
||||
- `petowner()` - Pet owners (guest and registered)
|
||||
- `pet()` - Pets with species, age, etc.
|
||||
- `cart()` - Shopping carts
|
||||
- `service_request()` - Service requests
|
||||
- `filter_services()` - Service filtering by species/neighborhood
|
||||
- `filter_categories()` - Category filtering
|
||||
- `calculate_cart_summary()` - Cart totals with discounts
|
||||
## HTTP API
|
||||
|
||||
### MercadoPagoDataGenerator (mercadopago.py)
|
||||
Mounted at `/tools/datagen`:
|
||||
|
||||
Generates MercadoPago API responses:
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/generators` | loaded generator files and their models |
|
||||
| `GET /api/models` | models for one generator (`?generator=<name>`) |
|
||||
| `POST /api/generate` | `{model, count, generator?, kwargs}` → generated items |
|
||||
| `GET /api/schema` | graphgen-compatible schema, when the generator exposes one |
|
||||
|
||||
- `preference()` - Checkout Pro preference
|
||||
- `payment()` - Payment (Checkout API/Bricks)
|
||||
- `merchant_order()` - Merchant order
|
||||
- `oauth_token()` - OAuth token exchange
|
||||
- `webhook_notification()` - Webhook payloads
|
||||
With one generator loaded, `generator` can be omitted everywhere — the only one is used.
|
||||
|
||||
## Examples
|
||||
|
||||
### Generate a complete turnero flow
|
||||
|
||||
```python
|
||||
from ward.tools.datagen.amar import AmarDataGenerator
|
||||
|
||||
# Step 1: Guest pet owner
|
||||
owner = AmarDataGenerator.petowner(
|
||||
address="Av. Santa Fe 1234, Palermo",
|
||||
is_guest=True
|
||||
)
|
||||
|
||||
# Step 2: Pet
|
||||
pet = AmarDataGenerator.pet(
|
||||
owner_id=owner["id"],
|
||||
name="Luna",
|
||||
species="DOG",
|
||||
age_value=3,
|
||||
age_unit="years"
|
||||
)
|
||||
|
||||
# Step 3: Cart
|
||||
cart = AmarDataGenerator.cart(owner_id=owner["id"])
|
||||
|
||||
# Step 4: Add services to cart
|
||||
services = AmarDataGenerator.filter_services(
|
||||
species="DOG",
|
||||
neighborhood_id=owner["neighborhood"]["id"]
|
||||
)
|
||||
|
||||
cart_with_items = AmarDataGenerator.calculate_cart_summary(
|
||||
cart,
|
||||
items=[
|
||||
{"service_id": services[0]["id"], "price": services[0]["price"], "quantity": 1, "pet_id": pet["id"]},
|
||||
]
|
||||
)
|
||||
|
||||
# Step 5: Service request
|
||||
request = AmarDataGenerator.service_request(cart_id=cart["id"])
|
||||
```
|
||||
|
||||
### Generate a payment flow
|
||||
|
||||
```python
|
||||
from ward.tools.datagen.mercadopago import MercadoPagoDataGenerator
|
||||
|
||||
# Create preference
|
||||
pref = MercadoPagoDataGenerator.preference(
|
||||
description="Visita a domicilio",
|
||||
total=95000,
|
||||
external_reference="SR-12345"
|
||||
)
|
||||
|
||||
# Simulate payment
|
||||
payment = MercadoPagoDataGenerator.payment(
|
||||
transaction_amount=95000,
|
||||
description="Visita a domicilio",
|
||||
status="approved",
|
||||
application_fee=45000 # Platform fee (split payment)
|
||||
)
|
||||
|
||||
# Webhook notification
|
||||
webhook = MercadoPagoDataGenerator.webhook_notification(
|
||||
topic="payment",
|
||||
resource_id=str(payment["id"])
|
||||
)
|
||||
```bash
|
||||
curl -X POST localhost:12000/tools/datagen/api/generate \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model": "user", "count": 3}'
|
||||
```
|
||||
|
||||
## Future Generators
|
||||
## Design principles
|
||||
|
||||
- `google.py` - Google API responses (Calendar, Sheets)
|
||||
- `whatsapp.py` - WhatsApp API responses
|
||||
- `slack.py` - Slack API responses
|
||||
1. **Room-owned** — domain vocabulary lives in `cfg/<room>/`, never in core.
|
||||
2. **Convention over registration** — a method name is a model name.
|
||||
3. **Flexible** — any field is overridable through `**kwargs`.
|
||||
4. **Stateless** — no global state between calls.
|
||||
5. **Standalone** — usable directly as a Python class, with or without the hub.
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="soleprint">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>datagen — Test Data Generator</title>
|
||||
<!-- Palette, fonts and the theme switcher. The :root block that used to sit
|
||||
here was one of eight copies that had already drifted apart. -->
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #1a1a1a;
|
||||
--border: #333;
|
||||
--text: #e5e5e5;
|
||||
--muted: #a3a3a3;
|
||||
--dim: #666;
|
||||
--amber: #d4a574;
|
||||
--amber-dim: #b8956a;
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--dim: #555568;
|
||||
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
@@ -493,5 +500,6 @@ document.addEventListener('keydown', e => {
|
||||
|
||||
init();
|
||||
</script>
|
||||
<script src="/theme.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -130,10 +130,19 @@ def _convert_modelgen(loader: Any, source: str) -> dict:
|
||||
fields = []
|
||||
for field in model_def.fields:
|
||||
type_str = _type_str(field.type_hint)
|
||||
fk_target = None
|
||||
# Prefer explicit metadata set by introspection extractors
|
||||
# (DatabaseExtractor / SqlAlchemyExtractor); fall back to inference.
|
||||
fk_target = getattr(field, "foreign_key", None)
|
||||
|
||||
if fk_target:
|
||||
relationships.append({
|
||||
"from_model": model_def.name,
|
||||
"from_field": field.name,
|
||||
"to_model": fk_target,
|
||||
"type": "FK",
|
||||
})
|
||||
# FK: type name that matches another model
|
||||
if type_str in all_names:
|
||||
elif type_str in all_names:
|
||||
fk_target = type_str
|
||||
relationships.append({
|
||||
"from_model": model_def.name,
|
||||
@@ -145,10 +154,12 @@ def _convert_modelgen(loader: Any, source: str) -> dict:
|
||||
elif type_str == "FK":
|
||||
fk_target = None # target unknown from extractor
|
||||
|
||||
is_pk = getattr(field, "primary_key", False) or field.name == "id"
|
||||
|
||||
fields.append({
|
||||
"name": field.name,
|
||||
"type": type_str,
|
||||
"pk": field.name == "id",
|
||||
"pk": is_pk,
|
||||
"fk": fk_target,
|
||||
"m2m": type_str == "M2M",
|
||||
"nullable": field.optional,
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="soleprint">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>graphgen — Schema Explorer</title>
|
||||
<!-- Palette, fonts and the theme switcher — see common/theme/. -->
|
||||
<!-- theme:baked-defaults — generated by common/theme/bake.py; do not edit -->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #1a1a1a;
|
||||
--border: #333;
|
||||
--text: #e5e5e5;
|
||||
--muted: #a3a3a3;
|
||||
--dim: #666;
|
||||
--amber: #d4a574;
|
||||
--amber-dim: #b8956a;
|
||||
--bg: #0d0d0f;
|
||||
--border: #2e2e38;
|
||||
--dim: #555568;
|
||||
--font-ui: Inter, "Segoe UI", system-ui, -apple-system, Arial, sans-serif;
|
||||
--muted: #8888a0;
|
||||
--surface: #16161a;
|
||||
--text: #e8e8f0;
|
||||
}
|
||||
|
||||
</style>
|
||||
<!-- /theme:baked-defaults -->
|
||||
<link rel="stylesheet" href="/theme.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
@@ -725,5 +731,6 @@ function svgEl(tag) {
|
||||
applyViewport();
|
||||
init();
|
||||
</script>
|
||||
<script src="/theme.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,7 +6,8 @@ Generates typed models from various sources to various output formats.
|
||||
Input sources:
|
||||
- Configuration files (soleprint config.json style)
|
||||
- Python dataclasses in schema/ folder
|
||||
- Existing codebases: Django, SQLAlchemy, Prisma (for extraction)
|
||||
- Existing codebases: Django, SQLAlchemy (for extraction)
|
||||
- Live databases: any SQLAlchemy dialect (PostgreSQL, MySQL, SQLite, ...)
|
||||
|
||||
Output formats:
|
||||
- pydantic: Pydantic BaseModel classes
|
||||
@@ -14,15 +15,17 @@ Output formats:
|
||||
- typescript: TypeScript interfaces
|
||||
- protobuf: Protocol Buffer definitions
|
||||
- prisma: Prisma schema
|
||||
- schema: graphgen-compatible schema.json (portable schema source)
|
||||
|
||||
Usage:
|
||||
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
|
||||
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
|
||||
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
|
||||
python -m soleprint.station.tools.modelgen from-db --url sqlite:///app.db --targets typescript,schema -o out/
|
||||
python -m soleprint.station.tools.modelgen list-formats
|
||||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
from .generator import GENERATORS, BaseGenerator
|
||||
from .loader import ConfigLoader, load_config
|
||||
|
||||
@@ -6,7 +6,10 @@ Generates typed models from various sources to various formats.
|
||||
Input sources:
|
||||
- from-config: Configuration files (soleprint config.json style)
|
||||
- from-schema: Python dataclasses in schema/ folder
|
||||
- extract: Existing codebases (Django, SQLAlchemy, Prisma)
|
||||
- extract: Existing codebases (Django, SQLAlchemy)
|
||||
- from-db: A live database, any SQLAlchemy dialect
|
||||
- from-openapi: An OpenAPI 3.x / Swagger 2.0 document
|
||||
- from-tabular: A directory of CSV/TSV/ODS spreadsheets
|
||||
|
||||
Output formats:
|
||||
- pydantic: Pydantic BaseModel classes
|
||||
@@ -14,12 +17,16 @@ Output formats:
|
||||
- typescript: TypeScript interfaces
|
||||
- protobuf: Protocol Buffer definitions
|
||||
- prisma: Prisma schema
|
||||
- schema: graphgen-compatible schema.json
|
||||
- datagen: BaseDataGenerator subclass for station's datagen tool
|
||||
|
||||
Usage:
|
||||
python -m soleprint.station.tools.modelgen --help
|
||||
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
|
||||
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
|
||||
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
|
||||
python -m soleprint.station.tools.modelgen from-openapi -s api.yaml -o out/ -t pydantic,schema
|
||||
python -m soleprint.station.tools.modelgen from-tabular -s ./sheets -o out/ -t pydantic,datagen
|
||||
python -m soleprint.station.tools.modelgen generate --config schema/modelgen.json
|
||||
"""
|
||||
|
||||
@@ -178,6 +185,135 @@ def cmd_extract(args):
|
||||
print("Done!")
|
||||
|
||||
|
||||
def cmd_from_db(args):
|
||||
"""Extract models from a live database (any SQLAlchemy dialect)."""
|
||||
from .loader.extract.database import DatabaseExtractor
|
||||
|
||||
include = {t.strip() for t in args.include.split(",")} if args.include else None
|
||||
exclude = {t.strip() for t in args.exclude.split(",")} if args.exclude else None
|
||||
|
||||
extractor = DatabaseExtractor(
|
||||
url=args.url,
|
||||
schema=args.schema,
|
||||
include=include,
|
||||
exclude=exclude,
|
||||
)
|
||||
|
||||
print(f"Reflecting database: {args.url}")
|
||||
try:
|
||||
models, enums = extractor.extract()
|
||||
except RuntimeError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Extracted {len(models)} models, {len(enums)} enums")
|
||||
|
||||
# Parse targets
|
||||
targets = [t.strip() for t in args.targets.split(",")]
|
||||
output_dir = Path(args.output)
|
||||
|
||||
for target in targets:
|
||||
if target not in GENERATORS:
|
||||
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
generator = GENERATORS[target]()
|
||||
ext = generator.file_extension()
|
||||
|
||||
# Determine output filename (use target name to avoid overwrites)
|
||||
if len(targets) == 1 and args.output.endswith(ext):
|
||||
output_file = output_dir
|
||||
else:
|
||||
output_file = output_dir / f"models_{target}{ext}"
|
||||
|
||||
print(f"Generating {target} to: {output_file}")
|
||||
generator.generate((models, enums), output_file)
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
def _emit(payload, targets_arg: str, output: str) -> None:
|
||||
"""Run one extraction result through every requested target.
|
||||
|
||||
The three older commands each carry their own copy of this loop; the two
|
||||
below share it, because they also have to pass datasets through and a
|
||||
fourth copy would be a fourth place to keep in step.
|
||||
"""
|
||||
targets = [t.strip() for t in targets_arg.split(",") if t.strip()]
|
||||
output_dir = Path(output)
|
||||
|
||||
for target in targets:
|
||||
if target not in GENERATORS:
|
||||
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
generator = GENERATORS[target]()
|
||||
ext = generator.file_extension()
|
||||
|
||||
# Determine output filename (use target name to avoid overwrites)
|
||||
if len(targets) == 1 and output.endswith(ext):
|
||||
output_file = output_dir
|
||||
else:
|
||||
output_file = output_dir / f"models_{target}{ext}"
|
||||
|
||||
print(f"Generating {target} to: {output_file}")
|
||||
generator.generate(payload, output_file)
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
def cmd_from_openapi(args):
|
||||
"""Generate models from an OpenAPI 3.x / Swagger 2.0 document."""
|
||||
from .loader.extract.openapi import OpenAPIExtractor
|
||||
|
||||
spec_path = Path(args.spec)
|
||||
if not spec_path.exists():
|
||||
print(f"Error: Spec not found: {spec_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
extractor = OpenAPIExtractor(spec_path)
|
||||
print(f"Reading spec: {spec_path}")
|
||||
try:
|
||||
models, enums = extractor.extract()
|
||||
except (RuntimeError, ValueError) as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
endpoints = extractor.endpoints()
|
||||
print(
|
||||
f"Extracted {len(models)} models, {len(enums)} enums, "
|
||||
f"{len(endpoints)} endpoints"
|
||||
)
|
||||
|
||||
_emit((models, enums), args.targets, args.output)
|
||||
|
||||
|
||||
def cmd_from_tabular(args):
|
||||
"""Generate models from a directory of CSV/TSV/ODS spreadsheets."""
|
||||
from .loader.extract.tabular import TabularExtractor
|
||||
|
||||
source_path = Path(args.source)
|
||||
if not source_path.exists():
|
||||
print(f"Error: Source not found: {source_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
extractor = TabularExtractor(source_path)
|
||||
print(f"Reading sheets: {source_path}")
|
||||
try:
|
||||
models, enums = extractor.extract()
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
datasets = extractor.datasets()
|
||||
rows = sum(len(d.rows) for d in datasets)
|
||||
print(f"Extracted {len(models)} models, {rows} rows")
|
||||
|
||||
# Datasets ride along as a third element: the datagen target seeds from
|
||||
# them, and every other target ignores the extra slot.
|
||||
_emit((models, enums, datasets), args.targets, args.output)
|
||||
|
||||
|
||||
def cmd_generate(args):
|
||||
"""Generate all targets from a JSON config file."""
|
||||
import json
|
||||
@@ -337,6 +473,108 @@ def main():
|
||||
)
|
||||
extract_parser.set_defaults(func=cmd_extract)
|
||||
|
||||
# from-db command (live database introspection, any dialect)
|
||||
db_parser = subparsers.add_parser(
|
||||
"from-db",
|
||||
help="Extract models from a live database (any SQLAlchemy dialect)",
|
||||
)
|
||||
db_parser.add_argument(
|
||||
"--url",
|
||||
"-u",
|
||||
type=str,
|
||||
required=True,
|
||||
help="SQLAlchemy connection URL (e.g. postgresql://…, mysql://…, sqlite:///path.db)",
|
||||
)
|
||||
db_parser.add_argument(
|
||||
"--schema",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Database schema to reflect (dialect-dependent; default: connection default)",
|
||||
)
|
||||
db_parser.add_argument(
|
||||
"--include",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Comma-separated table names to include (default: all)",
|
||||
)
|
||||
db_parser.add_argument(
|
||||
"--exclude",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Comma-separated table names to exclude",
|
||||
)
|
||||
db_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Output path (file or directory)",
|
||||
)
|
||||
db_parser.add_argument(
|
||||
"--targets",
|
||||
"-t",
|
||||
type=str,
|
||||
default="typescript",
|
||||
help=f"Comma-separated output targets ({formats_str})",
|
||||
)
|
||||
db_parser.set_defaults(func=cmd_from_db)
|
||||
|
||||
# from-openapi command (service contract -> models)
|
||||
openapi_parser = subparsers.add_parser(
|
||||
"from-openapi",
|
||||
help="Generate models from an OpenAPI 3.x / Swagger 2.0 document",
|
||||
)
|
||||
openapi_parser.add_argument(
|
||||
"--spec",
|
||||
"-s",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the spec (.json, .yaml or .yml; YAML needs PyYAML)",
|
||||
)
|
||||
openapi_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Output path (file or directory)",
|
||||
)
|
||||
openapi_parser.add_argument(
|
||||
"--targets",
|
||||
"-t",
|
||||
type=str,
|
||||
default="pydantic",
|
||||
help=f"Comma-separated output targets ({formats_str})",
|
||||
)
|
||||
openapi_parser.set_defaults(func=cmd_from_openapi)
|
||||
|
||||
# from-tabular command (spreadsheets -> models + rows)
|
||||
tabular_parser = subparsers.add_parser(
|
||||
"from-tabular",
|
||||
help="Generate models from a directory of CSV/TSV/ODS spreadsheets",
|
||||
)
|
||||
tabular_parser.add_argument(
|
||||
"--source",
|
||||
"-s",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Directory of sheets (or a single .csv/.tsv/.ods file)",
|
||||
)
|
||||
tabular_parser.add_argument(
|
||||
"--output",
|
||||
"-o",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Output path (file or directory)",
|
||||
)
|
||||
tabular_parser.add_argument(
|
||||
"--targets",
|
||||
"-t",
|
||||
type=str,
|
||||
default="pydantic",
|
||||
help=f"Comma-separated output targets ({formats_str}). "
|
||||
"The datagen target seeds from the imported rows.",
|
||||
)
|
||||
tabular_parser.set_defaults(func=cmd_from_tabular)
|
||||
|
||||
# generate command (config-driven multi-target)
|
||||
gen_parser = subparsers.add_parser(
|
||||
|
||||
@@ -8,12 +8,15 @@ Supported generators:
|
||||
- ProtobufGenerator: Protocol Buffer definitions
|
||||
- PrismaGenerator: Prisma schema
|
||||
- StrawberryGenerator: Strawberry type/input/enum classes
|
||||
- DatagenGenerator: BaseDataGenerator subclass for station's datagen tool
|
||||
"""
|
||||
|
||||
from typing import Dict, Type
|
||||
|
||||
from .base import BaseGenerator
|
||||
from .datagen import DatagenGenerator
|
||||
from .django import DjangoGenerator
|
||||
from .jsonschema import JsonSchemaGenerator
|
||||
from .prisma import PrismaGenerator
|
||||
from .protobuf import ProtobufGenerator
|
||||
from .pydantic import PydanticGenerator
|
||||
@@ -32,15 +35,20 @@ GENERATORS: Dict[str, Type[BaseGenerator]] = {
|
||||
"proto": ProtobufGenerator, # Alias
|
||||
"prisma": PrismaGenerator,
|
||||
"strawberry": StrawberryGenerator,
|
||||
"schema": JsonSchemaGenerator,
|
||||
"jsonschema": JsonSchemaGenerator, # Alias
|
||||
"datagen": DatagenGenerator,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"BaseGenerator",
|
||||
"DatagenGenerator",
|
||||
"PydanticGenerator",
|
||||
"DjangoGenerator",
|
||||
"StrawberryGenerator",
|
||||
"TypeScriptGenerator",
|
||||
"ProtobufGenerator",
|
||||
"PrismaGenerator",
|
||||
"JsonSchemaGenerator",
|
||||
"GENERATORS",
|
||||
]
|
||||
|
||||
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"):
|
||||
# SchemaLoader with gRPC definitions
|
||||
content = self._generate_from_loader(models)
|
||||
elif isinstance(models, tuple) and len(models) >= 3:
|
||||
# (messages, service_def) tuple
|
||||
elif isinstance(models, tuple) and len(models) >= 2:
|
||||
# (models, enums, ...) tuple — same first two slots as every other
|
||||
# generator; extra slots (datasets) belong to targets that use them.
|
||||
content = self._generate_from_definitions(models[0], models[1])
|
||||
elif isinstance(models, list):
|
||||
# List of dataclasses (MPR style)
|
||||
|
||||
@@ -8,8 +8,16 @@ Supported loaders:
|
||||
"""
|
||||
|
||||
from .config import ConfigLoader, load_config
|
||||
from .extract import EXTRACTORS, BaseExtractor, DjangoExtractor
|
||||
from .extract import (
|
||||
EXTRACTORS,
|
||||
BaseExtractor,
|
||||
DjangoExtractor,
|
||||
OpenAPIExtractor,
|
||||
TabularExtractor,
|
||||
)
|
||||
from .schema import (
|
||||
DatasetDefinition,
|
||||
EndpointDefinition,
|
||||
EnumDefinition,
|
||||
FieldDefinition,
|
||||
GrpcServiceDefinition,
|
||||
@@ -30,8 +38,12 @@ __all__ = [
|
||||
"FieldDefinition",
|
||||
"EnumDefinition",
|
||||
"GrpcServiceDefinition",
|
||||
"EndpointDefinition",
|
||||
"DatasetDefinition",
|
||||
# Extractors
|
||||
"BaseExtractor",
|
||||
"DjangoExtractor",
|
||||
"OpenAPIExtractor",
|
||||
"TabularExtractor",
|
||||
"EXTRACTORS",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
"""
|
||||
Extractors - Extract model definitions from existing codebases.
|
||||
|
||||
Supported frameworks:
|
||||
Supported sources:
|
||||
- Django: Extract from Django ORM models
|
||||
- SQLAlchemy: Extract from SQLAlchemy models (planned)
|
||||
- Prisma: Extract from Prisma schema (planned)
|
||||
- SQLAlchemy: Extract from SQLAlchemy models
|
||||
- OpenAPI: Extract from an OpenAPI 3.x / Swagger 2.0 document
|
||||
- Tabular: Extract from a directory of CSV/TSV/ODS spreadsheets
|
||||
"""
|
||||
|
||||
from typing import Dict, Type
|
||||
|
||||
from .base import BaseExtractor
|
||||
from .django import DjangoExtractor
|
||||
from .openapi import OpenAPIExtractor
|
||||
from .sqlalchemy_models import SqlAlchemyExtractor
|
||||
from .tabular import TabularExtractor
|
||||
|
||||
# Registry of available extractors
|
||||
# Registry of source extractors (auto-detectable via detect()).
|
||||
#
|
||||
# Ordering matters for `--framework auto`: detection runs in insertion order and
|
||||
# stops at the first match, so the two that inspect a *file* come after the two
|
||||
# that inspect a *source tree* and cannot be confused with them.
|
||||
#
|
||||
# Note: live-database introspection lives in database.py (DatabaseExtractor),
|
||||
# invoked explicitly via the `from-db` command since it takes a URL, not a path.
|
||||
EXTRACTORS: Dict[str, Type[BaseExtractor]] = {
|
||||
"django": DjangoExtractor,
|
||||
"sqlalchemy": SqlAlchemyExtractor,
|
||||
"openapi": OpenAPIExtractor,
|
||||
"tabular": TabularExtractor,
|
||||
}
|
||||
|
||||
__all__ = ["BaseExtractor", "DjangoExtractor", "EXTRACTORS"]
|
||||
__all__ = [
|
||||
"BaseExtractor",
|
||||
"DjangoExtractor",
|
||||
"SqlAlchemyExtractor",
|
||||
"OpenAPIExtractor",
|
||||
"TabularExtractor",
|
||||
"EXTRACTORS",
|
||||
]
|
||||
|
||||
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 importlib.util
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
||||
@@ -27,6 +27,15 @@ class FieldDefinition:
|
||||
type_hint: Any
|
||||
default: Any = dc.MISSING
|
||||
optional: bool = False
|
||||
# Optional DB/schema metadata (set by introspection extractors; ignored by
|
||||
# loaders/generators that don't need it).
|
||||
primary_key: bool = False
|
||||
foreign_key: Optional[str] = None # target model name
|
||||
unique: bool = False
|
||||
# True when foreign_key points at many rows rather than one — an array of
|
||||
# $ref, a m2m table. graphgen renders the two differently, so losing the
|
||||
# distinction would draw every collection as a single edge.
|
||||
many: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -55,6 +64,51 @@ class GrpcServiceDefinition:
|
||||
methods: List[Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class EndpointDefinition:
|
||||
"""Represents one HTTP operation on a service.
|
||||
|
||||
Models describe the shapes a service passes around; endpoints describe how
|
||||
it is called. Loaders that read a service contract (OpenAPI) or infer one
|
||||
(tabular) emit these alongside the models, and shuntgen turns them into
|
||||
routes. Loaders that only see shapes — dataclasses, a Django app — emit
|
||||
none, which is why this is not part of the BaseExtractor contract.
|
||||
"""
|
||||
|
||||
method: str # GET, POST, ...
|
||||
path: str # /pets/{petId}
|
||||
operation_id: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
# "collection" returns/accepts many, "item" one, "action" neither.
|
||||
kind: str = "item"
|
||||
model: Optional[str] = None # what this operation is about
|
||||
request_model: Optional[str] = None
|
||||
response_model: Optional[str] = None
|
||||
response_is_list: bool = False
|
||||
# Set when the collection arrives wrapped — {"items": [...], "total": n}
|
||||
# rather than a bare array. Answering a wrapped endpoint with an array is
|
||||
# the kind of mismatch a client only discovers at parse time.
|
||||
envelope_key: Optional[str] = None
|
||||
status: int = 200
|
||||
path_params: List[str] = field(default_factory=list)
|
||||
example: Any = None # response example carried through from the source
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatasetDefinition:
|
||||
"""Concrete rows harvested next to a model.
|
||||
|
||||
Importing a spreadsheet yields both a shape and the data that shaped it.
|
||||
Throwing the rows away would mean generating a service that answers with
|
||||
invented values when the real ones were right there.
|
||||
"""
|
||||
|
||||
model: str
|
||||
rows: List[Dict[str, Any]] = field(default_factory=list)
|
||||
source: Optional[str] = None # file the rows came from
|
||||
collection: Optional[str] = None # url-facing name, e.g. "customers"
|
||||
|
||||
|
||||
class SchemaLoader:
|
||||
"""Loads model definitions from Python dataclasses in schema/ folder."""
|
||||
|
||||
|
||||
@@ -4,11 +4,17 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "soleprint-modelgen"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Multi-source, multi-target model code generator"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
# Optional extras. Core modelgen is pure-stdlib and standalone; live-database
|
||||
# extraction (`from-db`) needs SQLAlchemy plus a driver for non-sqlite dialects
|
||||
# (e.g. psycopg2 for PostgreSQL, pymysql for MySQL).
|
||||
[project.optional-dependencies]
|
||||
db = ["sqlalchemy>=2.0"]
|
||||
|
||||
[project.scripts]
|
||||
modelgen = "modelgen.__main__:main"
|
||||
|
||||
|
||||
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)}]",
|
||||
"enum": lambda base: base.__name__,
|
||||
"dataclass": lambda base: base.__name__,
|
||||
# DB- and spec-shaped hints. Without these an int64 column resolves through
|
||||
# the "unknown -> str" fallback and a BigIntegerField arrives as a string.
|
||||
"bigint": lambda _: "int",
|
||||
"text": lambda _: "str",
|
||||
"bytes": lambda _: "bytes",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -95,6 +100,11 @@ TS_RESOLVERS: dict[Any, Callable[[Any], str]] = {
|
||||
"list": _resolve_ts_list,
|
||||
"enum": lambda base: base.__name__,
|
||||
"dataclass": lambda base: base.__name__,
|
||||
# JS has no 64-bit integer literal type, so int64 is a number like any
|
||||
# other; text is a string; binary arrives base64-encoded over JSON.
|
||||
"bigint": lambda _: "number",
|
||||
"text": lambda _: "string",
|
||||
"bytes": lambda _: "string",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pawprint Wrapper - Demo</title>
|
||||
<title>Sidebar Wrapper - Demo</title>
|
||||
<link rel="stylesheet" href="sidebar.css">
|
||||
<style>
|
||||
/* Demo page styles */
|
||||
@@ -19,7 +19,7 @@
|
||||
transition: margin-right 0.3s ease;
|
||||
}
|
||||
|
||||
#pawprint-sidebar.expanded ~ #demo-content {
|
||||
#spr-sidebar.expanded ~ #demo-content {
|
||||
margin-right: var(--sidebar-width);
|
||||
}
|
||||
|
||||
@@ -105,14 +105,14 @@
|
||||
|
||||
<div id="demo-content">
|
||||
<div class="demo-header">
|
||||
<h1>🐾 Pawprint Wrapper</h1>
|
||||
<p>Development tools sidebar for any pawprint-managed nest</p>
|
||||
<h1>Sidebar Wrapper</h1>
|
||||
<p>Development tools sidebar for any soleprint-managed room</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>👋 Quick Start</h2>
|
||||
<p>
|
||||
This is a standalone demo of the Pawprint Wrapper sidebar.
|
||||
This is a standalone demo of the sidebar wrapper.
|
||||
Click the toggle button on the right edge of the screen, or press
|
||||
<span class="kbd">Ctrl</span> + <span class="kbd">Shift</span> + <span class="kbd">P</span>
|
||||
to open the sidebar.
|
||||
|
||||
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
|
||||
|