From 9a6337e4933728d7aa3ade210ce9aa36530a0369 Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Mon, 10 Aug 2026 05:36:32 -0300 Subject: [PATCH] updates 33.1 84 --- .gitignore | 4 + build.py | 220 ++++++++ docs/data/en/export.md | 124 +++++ docs/data/en/station-cabinets.md | 107 ++++ docs/data/en/station-datagen.md | 103 +++- docs/data/en/station-modelgen.md | 117 +++-- docs/data/en/station-shuntgen.md | 129 +++++ docs/data/topics.json | 3 + soleprint/artery/index.html | 124 ++--- .../artery/shunts/mercadopago/api/routes.py | 11 +- .../artery/shunts/mercadopago/datagen.py | 182 +++++++ soleprint/artery/shunts/mercadopago/main.py | 6 +- soleprint/atlas/index.html | 30 +- soleprint/common/theme/theme.js | 118 +++++ soleprint/common/theme/themes/mcrn.css | 110 ++++ soleprint/common/theme/themes/soleprint.css | 113 ++++ soleprint/common/theme/tokens.css | 217 ++++++++ soleprint/index.html | 60 +-- soleprint/requirements.txt | 6 + soleprint/run.py | 156 +++++- soleprint/station/cabinets/README.md | 91 ++++ .../station/cabinets/airflow/cabinet.json | 22 + .../station/cabinets/airflow/service.yml | 37 ++ .../station/cabinets/postgres/cabinet.json | 20 + .../station/cabinets/postgres/service.yml | 24 + soleprint/station/cabinets/redis/cabinet.json | 17 + soleprint/station/cabinets/redis/service.yml | 14 + soleprint/station/index.html | 70 ++- .../tools/datagen/templates/index.html | 19 +- .../tools/graphgen/templates/index.html | 18 +- soleprint/station/tools/modelgen/__main__.py | 152 +++++- .../tools/modelgen/generator/__init__.py | 4 + .../tools/modelgen/generator/datagen.py | 399 ++++++++++++++ .../tools/modelgen/generator/jsonschema.py | 3 +- .../tools/modelgen/generator/protobuf.py | 5 +- .../station/tools/modelgen/loader/__init__.py | 14 +- .../tools/modelgen/loader/extract/__init__.py | 20 +- .../tools/modelgen/loader/extract/openapi.py | 440 ++++++++++++++++ .../tools/modelgen/loader/extract/tabular.py | 450 ++++++++++++++++ .../station/tools/modelgen/loader/schema.py | 51 +- .../station/tools/modelgen/tests/__init__.py | 18 + .../tools/modelgen/tests/test_extractors.py | 406 +++++++++++++++ soleprint/station/tools/modelgen/types.py | 10 + soleprint/station/tools/shuntgen/README.md | 127 +++++ soleprint/station/tools/shuntgen/__init__.py | 29 ++ soleprint/station/tools/shuntgen/__main__.py | 239 +++++++++ soleprint/station/tools/shuntgen/api.py | 354 +++++++++++++ soleprint/station/tools/shuntgen/emit.py | 462 +++++++++++++++++ .../tools/shuntgen/fixtures/petstore.yaml | 147 ++++++ .../shuntgen/fixtures/sheets/customers.csv | 6 + .../shuntgen/fixtures/sheets/invoices.csv | 7 + .../shuntgen/fixtures/sheets/line_items.ods | Bin 0 -> 1076 bytes soleprint/station/tools/shuntgen/runtime.py | 489 ++++++++++++++++++ .../tools/shuntgen/templates/index.html | 294 +++++++++++ .../tools/shuntgen/templates/shunt_ui.html | 242 +++++++++ 55 files changed, 6387 insertions(+), 253 deletions(-) create mode 100644 docs/data/en/export.md create mode 100644 docs/data/en/station-cabinets.md create mode 100644 docs/data/en/station-shuntgen.md create mode 100644 soleprint/artery/shunts/mercadopago/datagen.py create mode 100644 soleprint/common/theme/theme.js create mode 100644 soleprint/common/theme/themes/mcrn.css create mode 100644 soleprint/common/theme/themes/soleprint.css create mode 100644 soleprint/common/theme/tokens.css create mode 100644 soleprint/station/cabinets/README.md create mode 100644 soleprint/station/cabinets/airflow/cabinet.json create mode 100644 soleprint/station/cabinets/airflow/service.yml create mode 100644 soleprint/station/cabinets/postgres/cabinet.json create mode 100644 soleprint/station/cabinets/postgres/service.yml create mode 100644 soleprint/station/cabinets/redis/cabinet.json create mode 100644 soleprint/station/cabinets/redis/service.yml create mode 100644 soleprint/station/tools/modelgen/generator/datagen.py create mode 100644 soleprint/station/tools/modelgen/loader/extract/openapi.py create mode 100644 soleprint/station/tools/modelgen/loader/extract/tabular.py create mode 100644 soleprint/station/tools/modelgen/tests/__init__.py create mode 100644 soleprint/station/tools/modelgen/tests/test_extractors.py create mode 100644 soleprint/station/tools/shuntgen/README.md create mode 100644 soleprint/station/tools/shuntgen/__init__.py create mode 100644 soleprint/station/tools/shuntgen/__main__.py create mode 100644 soleprint/station/tools/shuntgen/api.py create mode 100644 soleprint/station/tools/shuntgen/emit.py create mode 100644 soleprint/station/tools/shuntgen/fixtures/petstore.yaml create mode 100644 soleprint/station/tools/shuntgen/fixtures/sheets/customers.csv create mode 100644 soleprint/station/tools/shuntgen/fixtures/sheets/invoices.csv create mode 100644 soleprint/station/tools/shuntgen/fixtures/sheets/line_items.ods create mode 100644 soleprint/station/tools/shuntgen/runtime.py create mode 100644 soleprint/station/tools/shuntgen/templates/index.html create mode 100644 soleprint/station/tools/shuntgen/templates/shunt_ui.html diff --git a/.gitignore b/.gitignore index 465bd67..986c43b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ 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/ diff --git a/build.py b/build.py index d73dd8e..cacdd8a 100644 --- a/build.py +++ b/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//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): diff --git a/docs/data/en/export.md b/docs/data/en/export.md new file mode 100644 index 0000000..9b06ac5 --- /dev/null +++ b/docs/data/en/export.md @@ -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//`, 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 [\|all\|models]` | `ctrl/build.sh` | compile a room into `gen/` | +| `make start [] [-d]` | `ctrl/start.sh` | run a built room's compose stack | +| `make stop []` | `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 ` runs these in order: + +1. **Clean** `gen//`. 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//config.json` lands in `cfg/`, + `data/*.json` in `data/`, and anything under `cfg//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// + / 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//` 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. diff --git a/docs/data/en/station-cabinets.md b/docs/data/en/station-cabinets.md new file mode 100644 index 0000000..d812010 --- /dev/null +++ b/docs/data/en/station-cabinets.md @@ -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//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// + 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/.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. diff --git a/docs/data/en/station-datagen.md b/docs/data/en/station-datagen.md index 08ea501..ea5dff3 100644 --- a/docs/data/en/station-datagen.md +++ b/docs/data/en/station-datagen.md @@ -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//soleprint/station/tools/datagen/ # Room-specific generators +soleprint/station/tools/datagen/ # base class, api, UI +cfg//soleprint/station/tools/datagen/ # the room's generators ``` After build, both merge into `gen//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. + +## HTTP API + +Mounted under `/station/tools/datagen/`: + +| 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 | -## Room Configuration +## Feeding graphgen -Room generators live in `cfg//soleprint/station/tools/datagen/`. They are fully self-contained -- they define their own models, factories, and output formats. +A generator that overrides `schema()` is surfaced at `/api/schema` in the format +[graphgen](#station-graphgen) reads, so the same definition draws the diagram: -The core module provides: -- Base generator class with faker instance -- CLI entry point -- Output formatting (JSON, CSV) +```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:` and `M2M:` are how relations are written. modelgen's +`datagen` target emits this method for you. diff --git a/docs/data/en/station-modelgen.md b/docs/data/en/station-modelgen.md index acfe89d..a66a06a 100644 --- a/docs/data/en/station-modelgen.md +++ b/docs/data/en/station-modelgen.md @@ -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//models/`. +The parse also yields the *operations*, which is what +[shuntgen](#station-shuntgen) turns into routes. -``` -gen//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:` and `M2M:`. +- **`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_`. + +## In a build + +`build.py` calls modelgen during every room build, writing +`gen//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//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. diff --git a/docs/data/en/station-shuntgen.md b/docs/data/en/station-shuntgen.md new file mode 100644 index 0000000..2044d62 --- /dev/null +++ b/docs/data/en/station-shuntgen.md @@ -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// + 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_.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_.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. diff --git a/docs/data/topics.json b/docs/data/topics.json index e22d553..ddeaf32 100644 --- a/docs/data/topics.json +++ b/docs/data/topics.json @@ -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"}} ] diff --git a/soleprint/artery/index.html b/soleprint/artery/index.html index a35ef6a..093310e 100644 --- a/soleprint/artery/index.html +++ b/soleprint/artery/index.html @@ -1,5 +1,5 @@ - + @@ -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,16 @@ } .attachment-label { font-size: 0.85rem; - color: #a3a3a3; + color: var(--muted); margin-bottom: 0.5rem; } - + + +
@@ -1824,5 +1829,6 @@ } }); - + + diff --git a/soleprint/artery/shunts/mercadopago/api/routes.py b/soleprint/artery/shunts/mercadopago/api/routes.py index 5b4807c..558357a 100755 --- a/soleprint/artery/shunts/mercadopago/api/routes.py +++ b/soleprint/artery/shunts/mercadopago/api/routes.py @@ -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() diff --git a/soleprint/artery/shunts/mercadopago/datagen.py b/soleprint/artery/shunts/mercadopago/datagen.py new file mode 100644 index 0000000..00e5a34 --- /dev/null +++ b/soleprint/artery/shunts/mercadopago/datagen.py @@ -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)}, + } diff --git a/soleprint/artery/shunts/mercadopago/main.py b/soleprint/artery/shunts/mercadopago/main.py index f9d9a0f..c62cec0 100755 --- a/soleprint/artery/shunts/mercadopago/main.py +++ b/soleprint/artery/shunts/mercadopago/main.py @@ -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)", diff --git a/soleprint/atlas/index.html b/soleprint/atlas/index.html index dea9e62..12332eb 100644 --- a/soleprint/atlas/index.html +++ b/soleprint/atlas/index.html @@ -1,5 +1,5 @@ - + @@ -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,12 @@ opacity: 0.5; } - + + +
@@ -265,5 +270,6 @@ {% if soleprint_url %}← Soleprint{% else %}← Soleprint{% endif %} - + + diff --git a/soleprint/common/theme/theme.js b/soleprint/common/theme/theme.js new file mode 100644 index 0000000..d24c350 --- /dev/null +++ b/soleprint/common/theme/theme.js @@ -0,0 +1,118 @@ +/* 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. + * + * + * + * + * Resolution order, strongest first: + * 1. ?theme=mcrn — a link that carries its own theme + * 2. localStorage — what this browser last chose + * 3. — 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"; + + var THEMES = ["soleprint", "mcrn"]; + 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 — 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 = theme === "mcrn" ? "MCRN" : "SPR"; + 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(), + }; +})(); diff --git a/soleprint/common/theme/themes/mcrn.css b/soleprint/common/theme/themes/mcrn.css new file mode 100644 index 0000000..6861504 --- /dev/null +++ b/soleprint/common/theme/themes/mcrn.css @@ -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", "Fira Code", "SF Mono", monospace; + --font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace; + --font-heading: "JetBrains Mono", "Fira Code", "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; +} diff --git a/soleprint/common/theme/themes/soleprint.css b/soleprint/common/theme/themes/soleprint.css new file mode 100644 index 0000000..afbe6b7 --- /dev/null +++ b/soleprint/common/theme/themes/soleprint.css @@ -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", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --font-heading: "Inter", system-ui, 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 18 fill / 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; +} diff --git a/soleprint/common/theme/tokens.css b/soleprint/common/theme/tokens.css new file mode 100644 index 0000000..d0289d9 --- /dev/null +++ b/soleprint/common/theme/tokens.css @@ -0,0 +1,217 @@ +/* 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 , 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. + */ + +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;600&display=swap"); + +: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: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", "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); +} diff --git a/soleprint/index.html b/soleprint/index.html index fba6b0a..a06bd59 100644 --- a/soleprint/index.html +++ b/soleprint/index.html @@ -1,5 +1,5 @@ - + @@ -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,20 @@ 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); } {% if managed %} {% endif %} - + +
@@ -428,5 +429,6 @@
soleprint
- + + diff --git a/soleprint/requirements.txt b/soleprint/requirements.txt index c66a860..b151a64 100644 --- a/soleprint/requirements.txt +++ b/soleprint/requirements.txt @@ -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 diff --git a/soleprint/run.py b/soleprint/run.py index 68cd2d1..4414422 100644 --- a/soleprint/run.py +++ b/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,67 @@ 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 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 ("soleprint", "mcrn") 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 tag.""" + return {"theme": get_default_theme(), "themes": ["soleprint", "mcrn"]} + + @app.get("/sidebar.css") def sidebar_css(): """Serve sidebar CSS for injection.""" diff --git a/soleprint/station/cabinets/README.md b/soleprint/station/cabinets/README.md new file mode 100644 index 0000000..0cf0ac9 --- /dev/null +++ b/soleprint/station/cabinets/README.md @@ -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// + cabinet.json what it is, what it needs, what it exports + service.yml the compose service, verbatim +``` + +## Turning one on + +Add `cfg//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 ` 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/.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. diff --git a/soleprint/station/cabinets/airflow/cabinet.json b/soleprint/station/cabinets/airflow/cabinet.json new file mode 100644 index 0000000..633e2c9 --- /dev/null +++ b/soleprint/station/cabinets/airflow/cabinet.json @@ -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." + ] +} diff --git a/soleprint/station/cabinets/airflow/service.yml b/soleprint/station/cabinets/airflow/service.yml new file mode 100644 index 0000000..b0cebee --- /dev/null +++ b/soleprint/station/cabinets/airflow/service.yml @@ -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 diff --git a/soleprint/station/cabinets/postgres/cabinet.json b/soleprint/station/cabinets/postgres/cabinet.json new file mode 100644 index 0000000..0c3c61b --- /dev/null +++ b/soleprint/station/cabinets/postgres/cabinet.json @@ -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}" + ] +} diff --git a/soleprint/station/cabinets/postgres/service.yml b/soleprint/station/cabinets/postgres/service.yml new file mode 100644 index 0000000..3979fd3 --- /dev/null +++ b/soleprint/station/cabinets/postgres/service.yml @@ -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 diff --git a/soleprint/station/cabinets/redis/cabinet.json b/soleprint/station/cabinets/redis/cabinet.json new file mode 100644 index 0000000..548c01c --- /dev/null +++ b/soleprint/station/cabinets/redis/cabinet.json @@ -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." + ] +} diff --git a/soleprint/station/cabinets/redis/service.yml b/soleprint/station/cabinets/redis/service.yml new file mode 100644 index 0000000..1a8d638 --- /dev/null +++ b/soleprint/station/cabinets/redis/service.yml @@ -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 diff --git a/soleprint/station/index.html b/soleprint/station/index.html index ae7a693..8af590b 100644 --- a/soleprint/station/index.html +++ b/soleprint/station/index.html @@ -1,5 +1,5 @@ - + @@ -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,12 @@ opacity: 0.5; } - + + +
@@ -217,6 +222,20 @@ +
+

{{ (components.container.plural or 'cabinets')|title }}

+
    + {% for cabinet in cabinets %} +
  • + {{ cabinet.title }}{{ cabinet.status }} +
  • + {% else %} +
  • --
  • + {% endfor %} +
+
+

{{ (components.watcher.plural or 'monitors')|title }}

    @@ -263,5 +282,6 @@ {% if soleprint_url %}← Soleprint{% else %}← Soleprint{% endif %} - + + diff --git a/soleprint/station/tools/datagen/templates/index.html b/soleprint/station/tools/datagen/templates/index.html index 0640694..0c9057e 100644 --- a/soleprint/station/tools/datagen/templates/index.html +++ b/soleprint/station/tools/datagen/templates/index.html @@ -1,27 +1,19 @@ - + datagen — Test Data Generator + + + + +
    +

    shuntgen

    + a spec or a folder of sheets → a running fake service +
    + +
    +
    +

    Source

    +
    + + + + + +

    + A spec (.json/.yaml) becomes routes. Sheets (.csv/.tsv/.ods) become + models with CRUD over their real rows — upload them into a folder + to import several at once. +

    + + + +

    Shunt

    + + + +
    +
    + + +
    +
    + + +
    +
    + + + + +
    + + +
    + +
    +
    +
    + +
    +

    What it will serve

    +
    + + + + + +
    MethodPathDoesModel
    pick a source and preview
    +
    + +

    Existing shunts

    +
    + + + + + +
    NameKindSourceRoutesModels
    loading…
    +
    +
    +
    + + + + + diff --git a/soleprint/station/tools/shuntgen/templates/shunt_ui.html b/soleprint/station/tools/shuntgen/templates/shunt_ui.html new file mode 100644 index 0000000..e83f634 --- /dev/null +++ b/soleprint/station/tools/shuntgen/templates/shunt_ui.html @@ -0,0 +1,242 @@ + + + + + + + %%TITLE%% shunt + + + +
    +

    %%TITLE%%

    + shunt · %%NAME%% + %%ROUTE_COUNT%% routes from %%SOURCE%% +
    + +

    Routes

    +
    + + + + + +
    MethodPathDoesModelCalls
    loading…
    +

    GET routes are links — they open against this shunt.

    +
    + +
    +
    +

    Behaviour

    +
    + + + + + + +
    + + +
    +

    Applies immediately, in memory. Edit depot/config.json to persist.

    +
    +
    + +
    +

    Stored rows

    +
    loading…
    +
    +
    + +

    Pinned responses

    +
    +
    loading…
    +

    + Keys are "METHOD /path" and win over everything else. + Edit depot/responses.json, or POST to /mock/responses. +

    +
    + + + +