update 33.1 50

This commit is contained in:
2026-08-10 03:23:30 -03:00
parent 33f0559268
commit ef63b02554
23 changed files with 753 additions and 368 deletions

7
.gitignore vendored
View File

@@ -9,6 +9,13 @@ __pycache__/
.venv/
venv/
# Node
node_modules/
# Built library bundles (regenerate with `pnpm build` in the package).
# The dist is what a container boots; it is an artifact, not source.
dist/
# Generated runnable instance (entirely gitignored - regenerate with build.py)
gen/

View File

@@ -51,20 +51,19 @@ spr/
│ └── amar/ # Amar room config
│ ├── config.json
│ ├── data/
│ ├── artery/ # Amar-specific (merged into output)
│ │ ── shunts/amar/
├── atlas/ # Amar-specific books
│ │ ── books/
├── station/ # Amar-specific tools config
│ │ ── tools/datagen/
│ ├── soleprint/ # Room overlay — merged over soleprint/ on build
│ │ ── artery/ # room shunts, pulses
└── shunts/amar/
│ │ ── atlas/ # room books
└── books/
│ │ ── station/ # room tool configs
│ │ │ └── tools/datagen/
│ │ └── nginx/
│ ├── ctrl/ # Room lifecycle scripts (copied into gen/<room>/)
│ ├── link/ # Bridge to managed app
── soleprint/ # Soleprint docker config
│ ├── databrowse/
│ ├── tester/
│ ├── monitors/
│ └── models/
── amar/ # The managed app itself
├── ctrl/ # Build/run scripts
├── ctrl/ # Build/run scripts (see Build & Run)
└── gen/ # Built instances (gitignored)
├── standalone/
@@ -100,26 +99,38 @@ Each room in `cfg/` has:
- `config.json` - Framework branding/terminology
- `data/` - Data files (veins.json, shunts.json, etc.)
Room-specific system configs (merged into output):
- `artery/` - Room-specific shunts, pulses
- `atlas/` - Room-specific books
- `station/` - Room-specific tool configs (datagen, tester tests, etc.)
Room-specific system configs live under `cfg/<room>/soleprint/` and are merged over
the core `soleprint/` tree at build time:
- `soleprint/artery/` - Room-specific shunts, pulses
- `soleprint/atlas/` - Room-specific books
- `soleprint/station/` - Room-specific tool configs (datagen generators, tester tests)
A room's own lifecycle scripts live in `cfg/<room>/ctrl/` and land in
`gen/<room>/ctrl/` — that is what `make start <room>` dispatches to.
## Build & Run
`make` is the front door — one target per `ctrl/` script, with the subcommand as an
argument (`make cluster up`, not `make cluster-up`). The logic lives in the scripts,
never in the Makefile. `make help` lists every target.
```bash
# Build
python build.py # -> gen/standalone/
python build.py --cfg amar # -> gen/amar/
python build.py --all # -> all rooms
make # = make help
make build [room|all|models] # -> gen/<room>/ (default: standalone)
make start [room] [-d] # dispatches to gen/<room>/ctrl/start.sh
make stop [room]
make cluster [up|down|status] # the shared `spr` kind cluster
make component [list|sync|watch|publish|diff]
make deploy [--build|--sync-only]
```
# Run bare-metal
cd gen/standalone && python run.py
Every script stays runnable on its own — the standalone rule holds:
# Using ctrl scripts
./ctrl/build.sh [room]
./ctrl/start.sh [room] [-d]
./ctrl/stop.sh [room]
```bash
python build.py --cfg amar # -> gen/amar/
cd gen/standalone && python run.py # bare-metal
./ctrl/kind-up.sh # still works directly
cd gen/<room> && ./ctrl/start.sh # each room owns its lifecycle scripts
```
## Adding a New Room
@@ -129,12 +140,12 @@ mkdir -p cfg/newroom/data
cp cfg/standalone/config.json cfg/newroom/
cp -r cfg/standalone/data/* cfg/newroom/data/
# Add room-specific configs as needed:
# cfg/newroom/artery/shunts/...
# cfg/newroom/atlas/books/...
# cfg/newroom/station/tools/...
# Add room-specific configs as needed (note the soleprint/ overlay level):
# cfg/newroom/soleprint/artery/shunts/...
# cfg/newroom/soleprint/atlas/books/...
# cfg/newroom/soleprint/station/tools/datagen/<name>.py
python build.py --cfg newroom
make build newroom
```
## Ports

62
Makefile Normal file
View File

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

View File

@@ -419,7 +419,11 @@ def main():
elif args.all:
build(SPR_ROOT / "gen" / "standalone", None)
for room in (SPR_ROOT / "cfg").iterdir():
if room.is_dir() and room.name not in ("__pycache__", "standalone"):
# cfg/ is itself a git repo and rooms may carry dot-dirs — skip them,
# or --all tries to build ".git" as a room.
if room.name.startswith(".") or room.name == "__pycache__":
continue
if room.is_dir() and room.name != "standalone":
build(SPR_ROOT / "gen" / room.name, room.name)
else:
if args.output:

37
ctrl/build.sh Executable file
View File

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

24
ctrl/cluster.sh Executable file
View File

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

View File

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

35
ctrl/start.sh Executable file
View File

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

27
ctrl/stop.sh Executable file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,10 @@
// Framework public API
// Theme (tokens + base layer). Imported here so the visual identity is part of
// the bundle rather than something each consumer must remember to wire up —
// every component below styles itself with the variables it defines.
import './theme.css'
export { DataSource, type DataSourceStatus } from './datasources/DataSource'
export { SSEDataSource } from './datasources/SSEDataSource'
export { StaticDataSource } from './datasources/StaticDataSource'

View File

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

View File

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

View File

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

View File

@@ -1,178 +1,127 @@
# Tester - HTTP Contract Test Runner
# Tester HTTP Contract Test Runner
Web UI for discovering and running contract tests.
Discovers and runs contract tests against any environment, with a web UI for
visibility.
**Test Definitions****Tester (Runner + UI)****Target API**
## Quick Start
```bash
# Sync tests from production repo (local dev)
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
# Run locally
cd /home/mariano/wdir/ama/pawprint/ward
python -m tools.tester
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
python -m tester discover # list what was found
# Open in browser
http://localhost:12003/tester
# In a built instance, the UI is mounted by the hub:
# http://localhost:12000/tools/tester
```
## Architecture
## Where tests live
**Test Definitions****Tester (Runner + UI)****Target API**
**No test bodies are committed to core.** The tool ships the base class, the
runner and the UI. Tests belong to a room:
```
amar_django_back_contracts/
└── tests/contracts/ ← Test definitions (source of truth)
├── mascotas/
├── productos/
└── workflows/
ward/tools/tester/
├── tests/ ← Synced from contracts (deployment)
│ ├── mascotas/
│ ├── productos/
│ └── workflows/
├── base.py ← HTTP test base class
├── core.py ← Test discovery & execution
├── api.py ← FastAPI endpoints
└── templates/ ← Web UI
cfg/<room>/soleprint/station/tools/tester/tests/
```
## Strategy: Separation of Concerns
They are merged into the built instance and discovered from `tests/` there.
See [`tests/test_template.py`](tests/test_template.py) — an intentionally empty
test file whose docstring covers the execution modes, environment targeting, the
`ContractTestCase` surface, and a worked example.
1. **Tests live in production repo** (`amar_django_back_contracts`)
- Developers write tests alongside code
- Tests are versioned with the API
- PR reviews include test changes
Keeping tests in the room rather than the runner means they version alongside the
API they describe, and the runner stays reusable across projects.
2. **Tester consumes tests** (`ward/tools/tester`)
- Provides web UI for visibility
- Runs tests against any target (dev, stage, prod)
- Shows test coverage to product team
## Layout
3. **Deployment syncs tests**
- `sync-tests.sh` copies tests from contracts to tester
- Deployment script includes test sync
- Server always has latest tests
```
tester/
├── base.py # ContractTestCase — httpx + stdlib unittest
├── core.py # discovery & execution
├── cli.py # python -m tester [discover|run]
├── config.py # .env + environment overrides
├── api.py # FastAPI routes
├── environments.json # named targets
├── templates/ # web UI
├── gherkin/ # optional feature/scenario metadata mapping
├── playwright/ # browser adapter (scaffolded; see the template)
└── tests/
├── base.py
├── test_template.py # start here
└── example/ # fallback health check, runs with no room config
```
## Configuration
### Single Environment (.env)
### Single environment (.env)
```env
CONTRACT_TEST_URL=https://demo.amarmascotas.ar
CONTRACT_TEST_URL=https://api.example.com
CONTRACT_TEST_API_KEY=your-api-key-here
```
### Multiple Environments (environments.json)
### Multiple environments (environments.json)
Configure multiple target environments with individual tokens:
Same suite, many targets — this is the point of the tool.
```json
[
{
"id": "demo",
"name": "Demo",
"url": "https://demo.amarmascotas.ar",
"id": "local",
"name": "Local",
"url": "http://localhost:8000",
"api_key": "",
"description": "Demo environment for testing",
"description": "Local development server",
"default": true
},
{
"id": "dev",
"name": "Development",
"url": "https://dev.amarmascotas.ar",
"api_key": "dev-token-here",
"description": "Development environment"
},
{
"id": "prod",
"name": "Production",
"url": "https://amarmascotas.ar",
"api_key": "prod-token-here",
"description": "Production (use with caution!)"
"id": "stage",
"name": "Staging",
"url": "https://stage.example.com",
"api_key": "stage-token-here",
"description": "Staging environment"
}
]
```
**Environment Selector**: Available in UI header on both Runner and Filters pages. Selection persists via localStorage.
## Web UI Features
Selection is available in the UI header and persists via localStorage. Tokens are
per-environment; keep real ones in a room's gitignored config, never here.
- **Filters**: Advanced filtering by domain, module, status, and search
- **Runner**: Execute tests with real-time progress tracking
- **Multi-Environment**: Switch between dev/stage/prod with per-environment tokens
- **URL State**: Filter state persists via URL when running tests
- **Real-time Status**: See test results as they run
See the template for every `CONTRACT_TEST_*` variable.
## API Endpoints
## API
```
GET /tools/tester/ # Runner UI
GET /tools/tester/filters # Filters UI
GET /tools/tester/api/tests # List all tests
GET /tools/tester/api/tests/tree # Tests grouped as a tree
GET /tools/tester/api/environments # List environments
POST /tools/tester/api/environment/select # Switch environment
POST /tools/tester/api/run # Start test run
GET /tools/tester/api/run/{run_id} # Get run status (polling)
GET /tools/tester/api/run/{run_id} # Run status (polling)
GET /tools/tester/api/runs # List all runs
GET /tools/tester/api/features # Gherkin features
POST /tools/tester/api/features/sync # Sync feature files
```
## Usage Flow
### From Filters to Runner
### URL parameters
1. Go to `/tools/tester/filters`
2. Filter tests (domain, module, search)
3. Select tests to run
4. Click "Run Selected"
5. → Redirects to Runner with filters applied and auto-starts execution
The runner accepts deep links:
### URL Parameters
Runner accepts URL params for deep linking:
```
/tools/tester/?run=abc123&domains=mascotas&search=owner
```
- `run` - Auto-load results for this run ID
- `domains` - Filter by domains (comma-separated)
- `modules` - Filter by modules (comma-separated)
- `search` - Search term for test names
- `status` - Filter by status (passed,failed,skipped)
## Deployment
Tests are synced during deployment:
```bash
# Full deployment (includes test sync)
cd /home/mariano/wdir/ama/pawprint/deploy
./deploy.sh
# Or sync tests only
/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh
/tools/tester/?run=abc123&modules=customers&search=invoice
```
## Why This Design?
**Problem**: Tests scattered, no visibility, hard to demonstrate value
**Solution**:
- Tests in production repo (developer workflow)
- Tester provides visibility (product team, demos)
- Separation allows independent evolution
**Benefits**:
- Product team sees test coverage
- Demos show "quality dashboard"
- Tests protect marketplace automation work
- Non-devs can run tests via UI
- `run` — auto-load results for this run ID
- `domains` / `modules` — comma-separated filters
- `search` — search term for test names
- `status``passed,failed,skipped`
## Related
## Why this design
- Production tests: `/home/mariano/wdir/ama/amar_django_back_contracts/tests/contracts/`
- Sync script: `/home/mariano/wdir/ama/core_nest/pawprint/ctrl/sync-tests.sh`
- Ward system: `/home/mariano/wdir/ama/pawprint/ward/`
Tests scattered across repos give no visibility and are hard to demonstrate.
Keeping definitions with the API while the runner stays generic means the suite
evolves with the code, non-developers can run it from the UI, and the same tests
prove the contract in every environment you can point them at.

View File

@@ -1,10 +1,10 @@
"""
CLI entry point for contracts_http tool.
CLI entry point for the tester tool.
Usage:
python -m contracts_http discover
python -m contracts_http run
python -m contracts_http run mascotas
python -m tester discover
python -m tester run
python -m tester run customers # only tests matching a pattern
"""
from .cli import main

View File

@@ -113,7 +113,7 @@ def main(args=None):
# run command
run_parser = subparsers.add_parser("run", help="Run tests")
run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g., 'mascotas', 'pet_owners')")
run_parser.add_argument("pattern", nargs="?", help="Filter tests by pattern (e.g. 'customers', 'invoices')")
args = parser.parse_args(args)

View File

@@ -4,22 +4,22 @@ Map tests to Gherkin scenarios based on metadata.
Tests can declare their Gherkin metadata via docstrings:
```python
def test_coverage_check(self):
def test_create_then_read_back(self):
'''
Feature: Reservar turno veterinario
Scenario: Verificar cobertura en zona disponible
Tags: @smoke @coverage
Feature: Customer records
Scenario: A created customer can be read back
Tags: @smoke @customers
'''
```
Or via class docstrings:
```python
class TestCoverageFlow(ContractHTTPTestCase):
"""
Feature: Reservar turno veterinario
Tags: @coverage
"""
class TestCustomers(ContractTestCase):
'''
Feature: Customer records
Tags: @customers
'''
```
"""

View File

@@ -12,11 +12,16 @@ def sync_features_from_album(
tester_path: Optional[Path] = None
) -> dict:
"""
Sync .feature files from album/book/gherkin-samples/ to ward/tools/tester/features/.
Sync .feature files from an atlas book into the tester's features/ directory.
Feature files are room-owned and live in a book:
cfg/<room>/soleprint/atlas/books/gherkin-samples/
which lands at atlas/books/gherkin-samples/ in a built instance. They are
synced, not committed here (see features/.gitignore).
Args:
album_path: Path to album/book/gherkin-samples/ (auto-detected if None)
tester_path: Path to ward/tools/tester/features/ (auto-detected if None)
album_path: Path to the gherkin-samples book (auto-detected if None)
tester_path: Path to tester/features/ (auto-detected if None)
Returns:
Dict with sync stats: {synced: int, skipped: int, errors: int}
@@ -26,9 +31,9 @@ def sync_features_from_album(
tester_path = Path(__file__).parent.parent / "features"
if album_path is None:
# Attempt to find album in pawprint
pawprint_root = Path(__file__).parent.parent.parent.parent
album_path = pawprint_root / "album" / "book" / "gherkin-samples"
# parents[4] is the instance root — station/tools/tester/gherkin/sync.py
instance_root = Path(__file__).resolve().parents[4]
album_path = instance_root / "atlas" / "books" / "gherkin-samples"
# Ensure paths exist
if not album_path.exists():

View File

@@ -1,73 +1,42 @@
# Contract Tests
API contract tests organized by Django app, with optional workflow tests.
Black-box HTTP tests that validate an API contract. Framework-agnostic by
construction: they talk to a URL, so they run against any implementation and any
environment.
## Testing Modes
**No tests are committed here.** This directory holds the base class and a
template; test bodies belong to a room:
Two modes via `CONTRACT_TEST_MODE` environment variable:
| Mode | Command | Description |
|------|---------|-------------|
| **api** (default) | `pytest tests/contracts/` | Fast, Django test client, test DB |
| **live** | `CONTRACT_TEST_MODE=live pytest tests/contracts/` | Real HTTP, LiveServerTestCase, test DB |
```
cfg/<room>/soleprint/station/tools/tester/tests/
```
### Mode Comparison
They are merged into the built instance and discovered from there.
| | `api` (default) | `live` |
|---|---|---|
| **Base class** | `APITestCase` | `LiveServerTestCase` |
| **HTTP** | In-process (Django test client) | Real HTTP via `requests` |
| **Auth** | `force_authenticate()` | JWT tokens via API |
| **Database** | Django test DB (isolated) | Django test DB (isolated) |
| **Speed** | ~3-5 sec | ~15-30 sec |
| **Server** | None (in-process) | Auto-started by Django |
## Start here
### Key Point: Both Modes Use Test Database
[`test_template.py`](test_template.py) — an intentionally empty test file whose
docstring carries the whole story: the execution modes, environment targeting, the
`ContractTestCase` surface, and a worked example.
Neither mode touches your real database. Django automatically:
1. Creates a test database (prefixed with `test_`)
2. Runs migrations
3. Destroys it after tests complete
## The two rules
## File Structure
1. **One suite, any environment.** A test states what the API promises, never who
implements it or where it runs. Point it at a laptop container, a cluster
namespace, or a deployed host — the result should only change if the contract
broke.
```
tests/contracts/
├── base.py # Mode switcher (imports from base_api or base_live)
├── base_api.py # APITestCase implementation
├── base_live.py # LiveServerTestCase implementation
├── conftest.py # pytest-django configuration
├── endpoints.py # API paths (single source of truth)
├── helpers.py # Shared test data helpers
├── mascotas/ # Django app: mascotas
│ ├── test_pet_owners.py
│ ├── test_pets.py
│ └── test_coverage.py
├── productos/ # Django app: productos
│ ├── test_services.py
│ └── test_cart.py
├── solicitudes/ # Django app: solicitudes
│ └── test_service_requests.py
└── workflows/ # Multi-step API sequences (e.g., turnero booking flow)
└── test_turnero_general.py
```
2. **No helper framework tools.** stdlib `unittest` and `httpx`. No pytest
fixtures or plugins, no framework test client, no factories or DSL, no ORM or
database access. A test that needs framework helpers to express itself has
stopped testing the contract, and stopped being portable.
## Running Tests
## Running
```bash
# All contract tests
pytest tests/contracts/
# Single app
pytest tests/contracts/mascotas/
# Single file
pytest tests/contracts/mascotas/test_pet_owners.py
# Live mode (real HTTP)
CONTRACT_TEST_MODE=live pytest tests/contracts/
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
python -m tester discover # list what was found
```
Targets are configured in [`../environments.json`](../environments.json) and
selectable from the web UI. See the template for every environment variable.

View File

@@ -0,0 +1,143 @@
"""
TEMPLATE — how to write a contract test. Intentionally empty: no test runs from
this file, and no tests are committed to the core repo at all.
Tests belong to a room:
cfg/<room>/soleprint/station/tools/tester/tests/
They get merged into the built instance and discovered from there. Core ships the
base class, the runner and the UI — never anyone's test bodies.
THE POINT
─────────
One suite, any environment. A test says what the API promises; it never says who
implements it or where it runs. Point the same file at a container on your laptop,
a namespace in the cluster, or a deployed host, and the answer should only differ
if the contract actually broke.
That constrains how tests are written, which is the second idea:
NO HELPER FRAMEWORK TOOLS
─────────────────────────
Deliberately plain. A test makes an HTTP call and asserts on the response.
- stdlib `unittest`, not pytest fixtures/plugins/parametrize machinery
- `httpx` against a URL, not a framework test client
- no factories, no DSL, no ORM access, no database setup
- no in-process shortcuts — if the test can reach it, so can a client
The moment a test needs framework helpers to express itself, it has stopped
testing the contract and started testing the implementation. It also stops being
portable: helper-bound tests only run where that framework runs.
(Django, where it appears at all, is an optional private DB editor via its admin —
never the framework, and never something a test reaches into.)
MODES OF EXECUTION
──────────────────
A mode is how a test reaches the system under test. Only the first is built.
http direct BUILT, and the one that matters. `ContractTestCase` below: pure
httpx over the wire. Discovered by
`unittest.TestLoader().discover(pattern="test_*.py")`, so a test
MUST subclass `ContractTestCase` to appear in the runner.
browser SCAFFOLDED, not wired end to end, and rarely the right tool.
`playwright/runner.py` shells out (`npx playwright test
--reporter=json`) and parses the report.
Browser tests earn their cost on complex UIs. Dashboards, monitors
and log views are not that — they render what an endpoint already
returned, so testing the endpoint tests the dashboard. Reach for
this only when behaviour lives in the browser and nowhere else;
data visualisation may eventually qualify. Not now.
any language INTENDED, not built. The browser adapter is the shape it would take:
a runner owes only a command to invoke and a machine-readable report
to parse. Nothing about that requires the test to be Python — R, Go,
k6 or a shell script satisfy the same contract.
Environment targeting is orthogonal to mode. `environments.json` holds the targets:
[{"id": "local", "name": "Local", "url": "http://localhost:8000",
"api_key": "", "description": "...", "default": true}]
Select one via `POST /tools/tester/api/environment/select`, or set the env directly:
CONTRACT_TEST_URL required — base URL of the target
CONTRACT_TEST_AUTH_TYPE bearer (default) | api-key | none
CONTRACT_TEST_TOKEN bearer token; fetched from the token endpoint if unset
CONTRACT_TEST_API_KEY required when auth type is api-key
CONTRACT_TEST_TOKEN_ENDPOINT default /api/token/
CONTRACT_TEST_USER / CONTRACT_TEST_PASSWORD used to fetch a token
CONTRACT_TEST_URL=http://localhost:8000 python -m tester run
WRITING ONE
───────────
The domain below is the in-repo invoicing fixture (cfg/sample) — deliberately
generic. Substitute your own; the shape is the part that transfers.
from ..base import ContractTestCase
class TestCustomers(ContractTestCase):
'''Customer endpoints.'''
def test_list_returns_customers(self):
'''A list endpoint returns a list.'''
response = self.get("/api/customers/")
self.assert_status(response, 200)
self.assert_is_list(response.data)
def test_create_then_read_back(self):
'''What was written is what comes back.'''
created = self.post("/api/customers/", {"name": "Acme Ltd"})
self.assert_status(created, 201)
self.assert_has_fields(created.data, "id", "name")
fetched = self.get(f"/api/customers/{created.data['id']}/")
self.assert_status(fetched, 200)
self.assertEqual(fetched.data["name"], "Acme Ltd")
def test_unknown_customer_is_404(self):
'''Absence is reported, not guessed at.'''
self.assert_status(self.get("/api/customers/00000000/"), 404)
Inherited from `ContractTestCase` (see ../base.py) — this is the whole surface:
self.get / post / put / patch / delete auth headers applied, .data parsed
self.assert_status(response, code)
self.assert_has_fields(data, *names)
self.assert_is_list(data, min_length=0)
self.base_url / self.token / self.api_key
Conventions that keep a suite portable:
- Group by area in subfolders; put multi-step sequences under `workflows/`.
- Keep paths in one `endpoints.py` per room so a URL change is a one-line diff.
- Create what you need through the API and assert on what comes back. A test that
depends on data already being there only passes in the environment it was
written against, which defeats the point.
- Skip, don't fail, when the target is simply unreachable — an unreachable
environment is not a broken contract.
GHERKIN (OPTIONAL)
──────────────────
A test can declare feature/scenario metadata in its docstring; the mapper reads it
to tie runs back to `.feature` files. Feature files are synced, not committed
(see features/.gitignore).
def test_create_then_read_back(self):
'''
Feature: Customer records
Scenario: A created customer can be read back
Tags: @smoke @customers
'''
"""