Compare commits

...

9 Commits

Author SHA1 Message Date
47b4b87851 update gen script 2026-02-04 09:17:33 -03:00
b4081cff3e modelgen completed 2026-02-04 09:04:13 -03:00
buenosairesam
35796c0c3b updated docs 2026-01-27 09:51:42 -03:00
buenosairesam
0351e5c7a6 updated docs 2026-01-27 09:50:03 -03:00
buenosairesam
3df1465bf5 fix: jinja2 template syntax in artery index 2026-01-27 09:38:56 -03:00
buenosairesam
dcc5191ba3 fix: rename VNC to VPN vein 2026-01-27 09:24:20 -03:00
buenosairesam
220d3dc5a6 fix: pass rooms and depots to artery template to fix 500 error 2026-01-27 09:24:05 -03:00
buenosairesam
fa7bbe3953 Improve soleprint docs: bilingual EN/ES, system pages, architecture cleanup 2026-01-27 09:24:05 -03:00
buenosairesam
ed1c8f6c96 Add /spr/ route for soleprint index, fix sidebar system labels 2026-01-27 09:24:05 -03:00
58 changed files with 3878 additions and 563 deletions

View File

@@ -26,7 +26,6 @@ import argparse
import json
import logging
import shutil
import subprocess
import sys
from pathlib import Path
@@ -219,6 +218,8 @@ def build_link(output_dir: Path, cfg_name: str):
def generate_models(output_dir: Path, room: str):
"""Generate models using modelgen tool."""
from soleprint.station.tools.modelgen import ModelGenerator, load_config
config_path = SPR_ROOT / "cfg" / room / "config.json"
if not config_path.exists():
@@ -228,21 +229,18 @@ def generate_models(output_dir: Path, room: str):
models_file = output_dir / "models" / "pydantic" / "__init__.py"
models_file.parent.mkdir(parents=True, exist_ok=True)
cmd = [
sys.executable,
"-m",
"soleprint.station.tools.modelgen",
"from-config",
"--config",
str(config_path),
"--output",
str(models_file),
"--format",
"pydantic",
]
result = subprocess.run(cmd, cwd=SPR_ROOT)
return result.returncode == 0
try:
config = load_config(config_path)
generator = ModelGenerator(
config=config,
output_path=models_file,
output_format="pydantic",
)
generator.generate()
return True
except Exception as e:
log.error(f"Model generation failed: {e}")
return False
def copy_cfg(output_dir: Path, room: str):

View File

@@ -1,16 +1,143 @@
{
"managed": {
"name": "sample",
"repos": {
"frontend": "/path/to/your/frontend/repo",
"backend": "/path/to/your/backend/repo"
}
},
"framework": {
"name": "soleprint"
"name": "soleprint",
"slug": "soleprint",
"version": "0.1.0",
"description": "Development workflow and documentation system",
"tagline": "Mapping development footprints",
"icon": "",
"hub_port": 12030
},
"auth": {
"enabled": false
"enabled": true,
"provider": "google",
"allowed_domains": [],
"allowed_emails": [],
"session_secret": "ENV:AUTH_SESSION_SECRET"
},
"veins": []
"veins": ["google"],
"managed": {
"name": "sample"
},
"systems": [
{
"key": "data_flow",
"name": "artery",
"slug": "artery",
"title": "Artery",
"tagline": "Todo lo vital",
"icon": ""
},
{
"key": "documentation",
"name": "atlas",
"slug": "atlas",
"title": "Atlas",
"tagline": "Documentacion accionable",
"icon": ""
},
{
"key": "execution",
"name": "station",
"slug": "station",
"title": "Station",
"tagline": "Monitores, Entornos y Herramientas",
"icon": ""
}
],
"components": {
"shared": {
"config": {
"name": "room",
"title": "Room",
"description": "Runtime environment configuration",
"plural": "rooms"
},
"data": {
"name": "depot",
"title": "Depot",
"description": "Data storage / provisions",
"plural": "depots"
}
},
"data_flow": {
"connector": {
"name": "vein",
"title": "Vein",
"description": "Stateless API connector",
"plural": "veins"
},
"mock": {
"name": "shunt",
"title": "Shunt",
"description": "Fake connector for testing",
"plural": "shunts"
},
"composed": {
"name": "pulse",
"title": "Pulse",
"description": "Composed data flow",
"plural": "pulses",
"formula": "Vein + Room + Depot"
},
"app": {
"name": "plexus",
"title": "Plexus",
"description": "Full app with backend, frontend and DB",
"plural": "plexus"
}
},
"documentation": {
"pattern": {
"name": "template",
"title": "Template",
"description": "Documentation pattern",
"plural": "templates"
},
"library": {
"name": "book",
"title": "Book",
"description": "Documentation library"
},
"composed": {
"name": "book",
"title": "Book",
"description": "Composed documentation",
"plural": "books",
"formula": "Template + Depot"
}
},
"execution": {
"utility": {
"name": "tool",
"title": "Tool",
"description": "Execution utility",
"plural": "tools"
},
"watcher": {
"name": "monitor",
"title": "Monitor",
"description": "Service monitor",
"plural": "monitors"
},
"container": {
"name": "cabinet",
"title": "Cabinet",
"description": "Tool container",
"plural": "cabinets"
},
"workspace": {
"name": "desk",
"title": "Desk",
"description": "Execution workspace"
},
"composed": {
"name": "desk",
"title": "Desk",
"description": "Composed execution bundle",
"plural": "desks",
"formula": "Cabinet + Room + Depots"
}
}
}
}

View File

@@ -1,35 +1,102 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sample App</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f5f5f5;
}
.container {
text-align: center;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 { color: #333; }
p { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Sample App</h1>
<p>This is a sample managed room for Soleprint.</p>
<p>Replace this with your actual frontend.</p>
</div>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sample - Public Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
color: #e5e5e5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
text-align: center;
padding: 2rem;
}
h1 {
font-size: 3rem;
margin-bottom: 1rem;
background: linear-gradient(135deg, #3a86ff, #8338ec);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
p {
font-size: 1.2rem;
color: #a3a3a3;
margin-bottom: 2rem;
}
.status {
display: inline-block;
padding: 0.5rem 1rem;
background: rgba(58, 134, 255, 0.2);
border: 1px solid #3a86ff;
border-radius: 4px;
color: #3a86ff;
font-size: 0.9rem;
}
.info {
margin-top: 3rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
max-width: 400px;
}
.info h3 {
color: #8338ec;
margin-bottom: 1rem;
}
.info ul {
list-style: none;
text-align: left;
}
.info li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info li:last-child {
border-bottom: none;
}
a {
color: #3a86ff;
}
</style>
</head>
<body>
<div class="container">
<h1>Sample</h1>
<p>Public demo - open to any Gmail account</p>
<span class="status">Public Demo</span>
<div class="info">
<h3>Soleprint Managed Room</h3>
<ul>
<li>
With sidebar:
<a href="https://sample.spr.mcrn.ar"
>sample.spr.mcrn.ar</a
>
</li>
<li>
Standalone:
<a href="https://sample.mcrn.ar">sample.mcrn.ar</a>
</li>
<li>Login with any Google account</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -1,7 +1,6 @@
# =============================================================================
# SOLEPRINT - Sample Room Configuration
# Sample Soleprint Configuration
# =============================================================================
# Copy this to cfg/<your-room>/soleprint/.env and customize
# =============================================================================
# DEPLOYMENT
@@ -9,32 +8,24 @@
DEPLOYMENT_NAME=sample_spr
# =============================================================================
# NETWORK (unique per room to allow concurrent operation)
# NETWORK
# =============================================================================
NETWORK_NAME=sample_network
# =============================================================================
# PORTS (choose unique ports for each room)
# PORTS (unique per room)
# =============================================================================
SOLEPRINT_PORT=12020
SOLEPRINT_PORT=12030
# =============================================================================
# GOOGLE OAUTH (optional - for auth)
# GOOGLE OAUTH
# =============================================================================
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/spr/artery/google/oauth/callback
GOOGLE_CLIENT_ID=1076380473867-k6gvdg8etujj2e51bqejve78ft99hnqd.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
GOOGLE_REDIRECT_URI=http://sample.spr.local.ar/artery/google/oauth/callback
# =============================================================================
# AUTH
# =============================================================================
AUTH_SESSION_SECRET=change-this-in-production
# =============================================================================
# DATABASE (optional - if your app uses a database)
# =============================================================================
DB_HOST=sample_db
DB_PORT=5432
DB_NAME=sampledb
DB_USER=postgres
DB_PASSWORD=localdev123
AUTH_BYPASS=true
AUTH_SESSION_SECRET=sample-dev-secret-change-in-production

View File

@@ -1,4 +1,5 @@
{
"showcase_url": "https://sample.spr.mcrn.ar",
"framework": {
"name": "soleprint",
"slug": "soleprint",

View File

@@ -43,9 +43,9 @@
"system": "artery"
},
{
"name": "vnc",
"slug": "vnc",
"title": "VNC",
"name": "vpn",
"slug": "vpn",
"title": "VPN",
"status": "planned",
"system": "artery"
},

View File

@@ -38,7 +38,7 @@
<g id="clust6" class="cluster">
<title>cluster_room</title>
<polygon fill="none" stroke="#7b1fa2" stroke-dasharray="5,2" points="642,-92.75 642,-184.38 952,-184.38 952,-92.75 642,-92.75"/>
<text xml:space="preserve" text-anchor="middle" x="797" y="-165.18" font-family="Helvetica,sans-Serif" font-size="16.00">Managed Room (e.g., AMAR)</text>
<text xml:space="preserve" text-anchor="middle" x="797" y="-165.18" font-family="Helvetica,sans-Serif" font-size="16.00">Managed Room</text>
</g>
<!-- hub -->
<g id="node1" class="node">

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidebar Injection - Soleprint</title>
<link rel="stylesheet" href="styles.css" />
<style>
pre {
background: #1a1a1a;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.5;
}
code {
font-family: monospace;
background: #2a2a2a;
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
pre code {
background: none;
padding: 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th, td {
border: 1px solid #3f3f3f;
padding: 0.5rem;
text-align: left;
}
th {
background: #1a1a1a;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Sidebar Injection</h1>
<p class="subtitle">
<span class="lang-en">How managed room sidebar works</span>
<span class="lang-es">Como funciona el sidebar del managed room</span>
</p>
</header>
<main>
<section class="findings-section">
<h2>Overview</h2>
<p class="lang-en">The soleprint sidebar is injected into managed app pages using a hybrid nginx + JavaScript approach. This allows any frontend framework (React, Next.js, static HTML) to receive the sidebar without code modifications.</p>
<p class="lang-es">El sidebar de soleprint se inyecta en las paginas de apps manejadas usando un enfoque hibrido nginx + JavaScript. Esto permite que cualquier framework frontend (React, Next.js, HTML estatico) reciba el sidebar sin modificaciones de codigo.</p>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">How It Works</span>
<span class="lang-es">Como Funciona</span>
</h2>
<pre>
┌─────────────────────────────────────────────────────────────────┐
│ Browser Request │
│ http://room.spr.local.ar/ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Nginx │
│ │
│ 1. Routes /spr/* → soleprint:PORT (sidebar assets + API) │
│ 2. Routes /* → frontend:PORT (app pages) │
│ 3. Injects CSS+JS into HTML responses via sub_filter │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Browser Renders │
│ │
│ 1. Page loads with injected CSS (sidebar styles ready) │
│ 2. sidebar.js executes (deferred, after DOM ready) │
│ 3. JS fetches /spr/api/sidebar/config │
│ 4. JS creates sidebar DOM elements and injects into page │
│ 5. Sidebar appears on left side, pushes content with margin │
└─────────────────────────────────────────────────────────────────┘</pre>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Key Design Decisions</span>
<span class="lang-es">Decisiones de Diseno Clave</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3><span class="lang-en">Why nginx sub_filter?</span><span class="lang-es">Por que nginx sub_filter?</span></h3>
<p class="lang-en"><strong>Framework agnostic</strong>: Works with any frontend. No app changes needed. Easy to disable.</p>
<p class="lang-es"><strong>Agnostico de framework</strong>: Funciona con cualquier frontend. Sin cambios en la app. Facil de deshabilitar.</p>
</article>
<article class="finding-card">
<h3><span class="lang-en">Why inject into &lt;/head&gt;?</span><span class="lang-es">Por que inyectar en &lt;/head&gt;?</span></h3>
<p class="lang-en">Next.js and streaming SSR may not include &lt;/body&gt; in initial response. &lt;/head&gt; is always present.</p>
<p class="lang-es">Next.js y SSR streaming pueden no incluir &lt;/body&gt; en la respuesta inicial. &lt;/head&gt; siempre esta presente.</p>
</article>
<article class="finding-card">
<h3><span class="lang-en">Why JS instead of iframe?</span><span class="lang-es">Por que JS en vez de iframe?</span></h3>
<p class="lang-en">No isolation issues, better UX (no double scrollbars), simpler CSS with margin-left.</p>
<p class="lang-es">Sin problemas de aislamiento, mejor UX (sin doble scrollbar), CSS mas simple con margin-left.</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Nginx Configuration</span>
<span class="lang-es">Configuracion de Nginx</span>
</h2>
<p class="lang-en">The nginx config injects CSS+JS into HTML responses:</p>
<p class="lang-es">La config de nginx inyecta CSS+JS en las respuestas HTML:</p>
<pre><code>location / {
proxy_pass http://frontend:PORT;
proxy_set_header Accept-Encoding ""; # Required for sub_filter
# Inject sidebar
sub_filter '&lt;/head&gt;'
'&lt;link rel="stylesheet" href="/spr/sidebar.css"&gt;
&lt;script src="/spr/sidebar.js" defer&gt;&lt;/script&gt;&lt;/head&gt;';
sub_filter_once off;
sub_filter_types text/html;
}</code></pre>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Port Allocation</span>
<span class="lang-es">Asignacion de Puertos</span>
</h2>
<p class="lang-en">Each room uses unique ports for concurrent operation:</p>
<p class="lang-es">Cada room usa puertos unicos para operacion concurrente:</p>
<table>
<tr><th>Room</th><th>Soleprint</th><th>Frontend</th><th>Backend</th></tr>
<tr><td>amar</td><td>12000</td><td>3000</td><td>8001</td></tr>
<tr><td>dlt</td><td>12010</td><td>3010</td><td>-</td></tr>
<tr><td>sample</td><td>12020</td><td>3020</td><td>8020</td></tr>
</table>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Sidebar Config API</span>
<span class="lang-es">API de Config del Sidebar</span>
</h2>
<p class="lang-en">The sidebar JS fetches configuration from <code>/spr/api/sidebar/config</code>:</p>
<p class="lang-es">El JS del sidebar obtiene configuracion de <code>/spr/api/sidebar/config</code>:</p>
<pre><code>{
"room": "amar",
"soleprint_base": "/spr",
"auth_enabled": true,
"tools": {
"artery": "/spr/artery",
"atlas": "/spr/atlas",
"station": "/spr/station"
}
}</code></pre>
</section>
<section class="findings-section">
<h2>Troubleshooting</h2>
<div class="findings-grid">
<article class="finding-card">
<h3><span class="lang-en">Sidebar not appearing</span><span class="lang-es">Sidebar no aparece</span></h3>
<p class="lang-en">Check if soleprint is running. Verify nginx has <code>Accept-Encoding ""</code>. Hard refresh (Ctrl+Shift+R).</p>
<p class="lang-es">Verificar que soleprint esta corriendo. Verificar que nginx tiene <code>Accept-Encoding ""</code>. Refresco forzado (Ctrl+Shift+R).</p>
</article>
<article class="finding-card">
<h3><span class="lang-en">sub_filter not working</span><span class="lang-es">sub_filter no funciona</span></h3>
<p class="lang-en">Ensure <code>proxy_set_header Accept-Encoding ""</code> is set. Check response is <code>text/html</code>.</p>
<p class="lang-es">Asegurar que <code>proxy_set_header Accept-Encoding ""</code> esta seteado. Verificar que la respuesta es <code>text/html</code>.</p>
</article>
</div>
</section>
</main>
<footer>
<p><a href="../"><span class="lang-en">← Back to index</span><span class="lang-es">← Volver al indice</span></a></p>
</footer>
</body>
</html>

314
docs/artery/index.html Normal file
View File

@@ -0,0 +1,314 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Artery - Soleprint</title>
<link rel="stylesheet" href="../architecture/styles.css" />
<style>
.composition {
background: #1a1a1a;
border: 2px solid #b91c1c;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #fca5a5;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #fca5a5;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Artery</h1>
<p class="subtitle">Todo lo vital</p>
</header>
<main>
<section class="findings-section">
<h2>
<span class="lang-en">Model</span
><span class="lang-es">Modelo</span>
</h2>
<div class="model-grid">
<div class="composition">
<h3>Pulse</h3>
<p class="lang-en">
Composed data flow: a vein configured for a room
with storage
</p>
<p class="lang-es">
Flujo de datos compuesto: vein configurado para un
room con almacenamiento
</p>
<div class="components">
<div class="component">
<h4>Vein</h4>
<p class="lang-en">API connector</p>
<p class="lang-es">Conector API</p>
</div>
<div class="component">
<h4>Room</h4>
<p class="lang-en">Config/env</p>
<p class="lang-es">Config/entorno</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Data storage</p>
<p class="lang-es">Almacenamiento</p>
</div>
</div>
</div>
<div class="composition">
<h3>Shunt</h3>
<p class="lang-en">
Mock connector for testing - same interface, fake
data
</p>
<p class="lang-es">
Conector mock para testing - misma interfaz, datos
falsos
</p>
<div class="components">
<div class="component">
<h4>Vein Interface</h4>
<p class="lang-en">Same API shape</p>
<p class="lang-es">Misma forma de API</p>
</div>
<div class="component">
<h4>Mock Data</h4>
<p class="lang-en">Fake responses</p>
<p class="lang-es">Respuestas falsas</p>
</div>
</div>
</div>
<div class="composition">
<h3>Plexus</h3>
<p class="lang-en">
Full application when you need more than data flow
</p>
<p class="lang-es">
Aplicacion completa cuando necesitas mas que flujo
de datos
</p>
<div class="components">
<div class="component">
<h4>Backend</h4>
<p>FastAPI server</p>
</div>
<div class="component">
<h4>Frontend</h4>
<p>Web UI</p>
</div>
<div class="component">
<h4>Infra</h4>
<p class="lang-en">DB, queues, etc</p>
<p class="lang-es">DB, colas, etc</p>
</div>
</div>
</div>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Architecture</span
><span class="lang-es">Arquitectura</span>
</h2>
<img
src="../architecture/02-artery-hierarchy.svg"
alt="Artery Hierarchy"
style="
max-width: 100%;
background: white;
border-radius: 8px;
padding: 1rem;
"
/>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Components</span
><span class="lang-es">Componentes</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Vein</h3>
<p class="lang-en">
Stateless API connector. Connects to external
services like Google Sheets, Jira, Slack. Pure data
flow - no state, no storage.
</p>
<p class="lang-es">
Conector API sin estado. Conecta a servicios
externos como Google Sheets, Jira, Slack. Flujo de
datos puro.
</p>
</article>
<article class="finding-card">
<h3>Shunt</h3>
<p class="lang-en">
Mock connector for testing. Same interface as a vein
but returns fake data.
</p>
<p class="lang-es">
Conector mock para testing. Misma interfaz que un
vein pero devuelve datos falsos.
</p>
</article>
<article class="finding-card">
<h3>Pulse</h3>
<p class="lang-en">
Composed data flow. Formula:
<strong>Vein + Room + Depot</strong>.
</p>
<p class="lang-es">
Flujo de datos compuesto. Formula:
<strong>Vein + Room + Depot</strong>.
</p>
</article>
<article class="finding-card">
<h3>Plexus</h3>
<p class="lang-en">
Full application with backend, frontend, and
infrastructure.
</p>
<p class="lang-es">
Aplicacion completa con backend, frontend e
infraestructura.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Shared Components</span
><span class="lang-es">Componentes Compartidos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Room</h3>
<p class="lang-en">
Runtime environment configuration. Each room is an
isolated instance with its own config and
credentials.
</p>
<p class="lang-es">
Configuracion del entorno. Cada room es una
instancia aislada con su propia config y
credenciales.
</p>
</article>
<article class="finding-card">
<h3>Depot</h3>
<p class="lang-en">
Data storage / provisions. JSON files, configs,
cached responses.
</p>
<p class="lang-es">
Almacenamiento de datos. Archivos JSON, configs,
respuestas cacheadas.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Available Veins</span
><span class="lang-es">Veins Disponibles</span>
</h2>
<div class="findings-grid">
<a
href="../veins/index.html"
class="finding-card"
style="text-decoration: none"
>
<h3>Google</h3>
<p class="lang-en">
Google Sheets API. OAuth authentication, read/write
spreadsheets.
</p>
<p class="lang-es">
Google Sheets API. Autenticacion OAuth,
leer/escribir hojas de calculo.
</p>
</a>
<a
href="../veins/index.html"
class="finding-card"
style="text-decoration: none"
>
<h3>Jira</h3>
<p class="lang-en">
Jira Cloud API. Query issues, projects, sprints.
</p>
<p class="lang-es">
Jira Cloud API. Consultar issues, proyectos,
sprints.
</p>
</a>
<a
href="../veins/index.html"
class="finding-card"
style="text-decoration: none"
>
<h3>Slack</h3>
<p class="lang-en">
Slack API. Channels, messages, users.
</p>
<p class="lang-es">
Slack API. Canales, mensajes, usuarios.
</p>
</a>
</div>
</section>
</main>
<footer>
<p>
<a href="../"
><span class="lang-en">← Back to index</span
><span class="lang-es">← Volver al indice</span></a
>
</p>
</footer>
</body>
</html>

209
docs/atlas/index.html Normal file
View File

@@ -0,0 +1,209 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Atlas - Soleprint</title>
<link rel="stylesheet" href="../architecture/styles.css" />
<style>
.composition {
background: #1a1a1a;
border: 2px solid #15803d;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #86efac;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #86efac;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Atlas</h1>
<p class="subtitle">
<span class="lang-en">Actionable Documentation</span
><span class="lang-es">Documentacion Accionable</span>
</p>
</header>
<main>
<section class="findings-section">
<h2>
<span class="lang-en">Model</span
><span class="lang-es">Modelo</span>
</h2>
<div class="model-grid">
<div class="composition">
<h3>Plain Book</h3>
<p class="lang-en">
Static documentation with an index page
</p>
<p class="lang-es">
Documentacion estatica con una pagina indice
</p>
<div class="components">
<div class="component">
<h4>index.html</h4>
<p class="lang-en">Entry point</p>
<p class="lang-es">Punto de entrada</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Static content</p>
<p class="lang-es">Contenido estatico</p>
</div>
</div>
</div>
<div class="composition">
<h3>Templated Book</h3>
<p class="lang-en">
Dynamic docs from template + data elements
</p>
<p class="lang-es">
Docs dinamicos desde template + elementos de datos
</p>
<div class="components">
<div class="component">
<h4>Template</h4>
<p class="lang-en">Jinja2 pattern</p>
<p class="lang-es">Patron Jinja2</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Data elements</p>
<p class="lang-es">Elementos de datos</p>
</div>
</div>
</div>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Architecture</span
><span class="lang-es">Arquitectura</span>
</h2>
<img
src="../architecture/01-system-overview.svg"
alt="System Overview"
style="
max-width: 100%;
background: white;
border-radius: 8px;
padding: 1rem;
"
/>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Components</span
><span class="lang-es">Componentes</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Book</h3>
<p class="lang-en">
Collection of related documentation. Can be plain
(static HTML) or templated (template + depot
elements).
</p>
<p class="lang-es">
Coleccion de documentacion relacionada. Puede ser
plain (HTML estatico) o templated (template +
elementos de depot).
</p>
</article>
<article class="finding-card">
<h3>Template</h3>
<p class="lang-en">
Jinja2 templates that generate documentation. Define
the structure once, fill with data from depot.
</p>
<p class="lang-es">
Templates Jinja2 que generan documentacion. Definen
la estructura una vez, llenan con datos del depot.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Shared Components</span
><span class="lang-es">Componentes Compartidos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Room</h3>
<p class="lang-en">
Runtime environment configuration. Each room can
have its own atlas with project-specific books.
</p>
<p class="lang-es">
Configuracion del entorno. Cada room puede tener su
propio atlas con books especificos del proyecto.
</p>
</article>
<article class="finding-card">
<h3>Depot</h3>
<p class="lang-en">
Data storage. For plain books: static files. For
templated books: elements that fill the template.
</p>
<p class="lang-es">
Almacenamiento de datos. Para plain books: archivos
estaticos. Para templated books: elementos que
llenan el template.
</p>
</article>
</div>
</section>
</main>
<footer>
<p>
<a href="../"
><span class="lang-en">← Back to index</span
><span class="lang-es">← Volver al indice</span></a
>
</p>
</footer>
</body>
</html>

View File

@@ -5,39 +5,151 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soleprint - Documentation</title>
<link rel="stylesheet" href="architecture/styles.css" />
<style>
.one-liner {
font-size: 1.2rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
.demo-link {
display: inline-block;
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: var(--bg-primary);
text-decoration: none;
border-radius: 4px;
font-weight: 500;
}
.demo-link:hover {
opacity: 0.9;
}
.systems-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.system-card {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
border-left: 3px solid var(--accent);
}
.system-card h3 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.system-card p {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.arch-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.arch-card {
display: block;
background: var(--bg-secondary);
padding: 1rem;
border-radius: 8px;
text-decoration: none;
transition: transform 0.15s;
}
.arch-card:hover {
transform: translateY(-2px);
}
.arch-card h4 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.arch-card p {
margin: 0;
color: var(--text-secondary);
font-size: 0.85rem;
}
pre {
background: var(--bg-primary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin-top: 0.5rem;
}
</style>
<script src="lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Soleprint</h1>
<p class="subtitle">
Cada paso deja huella / Each step leaves a mark
<p class="subtitle">Cada paso deja huella</p>
<p class="one-liner">
<span class="lang-en"
>Pluggable stuff to tackle any challenge</span
>
<span class="lang-es"
>Piezas enchufables para cualquier desafio</span
>
</p>
<a href="https://sample.spr.mcrn.ar" class="demo-link">
<span class="lang-en">Try the Demo</span>
<span class="lang-es">Ver Demo</span>
</a>
</header>
<main>
<section class="findings-section">
<h2>Documentation</h2>
<div class="findings-grid">
<h2>
<span class="lang-en">The Three Systems</span>
<span class="lang-es">Los Tres Sistemas</span>
</h2>
<div class="systems-grid">
<a
href="architecture/index.html"
class="finding-card"
href="artery/"
class="system-card"
style="text-decoration: none"
>
<h3>Architecture</h3>
<p>
System overview, connector hierarchy, build flow,
and room configuration diagrams.
<h3>Artery</h3>
<p class="lang-en">
API connectors and data flow. Veins for real APIs,
shunts for mocks.
</p>
<p class="lang-es">
Conectores y flujo de datos. Veins para APIs reales,
shunts para mocks.
</p>
</a>
<a
href="veins/index.html"
class="finding-card"
href="atlas/"
class="system-card"
style="text-decoration: none"
>
<h3>Veins & Shunts</h3>
<p>
API connectors (Jira, Slack, Google) and mock
connectors for testing.
<h3>Atlas</h3>
<p class="lang-en">
Actionable documentation. Templates that generate
living docs.
</p>
<p class="lang-es">
Documentacion accionable. Templates que generan docs
vivos.
</p>
</a>
<a
href="station/"
class="system-card"
style="text-decoration: none"
>
<h3>Station</h3>
<p class="lang-en">
Tools and monitors. Everything to run, test, and
observe.
</p>
<p class="lang-es">
Herramientas y monitores. Todo para correr, testear,
y observar.
</p>
</a>
</div>
@@ -45,53 +157,106 @@
<section class="findings-section">
<h2>Quick Start</h2>
<div class="finding-card">
<h3>Build & Run</h3>
<pre
style="
background: var(--bg-primary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin-top: 0.5rem;
"
>
# Build standalone
python build.py
cd gen/standalone && .venv/bin/python run.py
<pre>
# Clone
git clone https://git.mcrn.ar/soleprint
cd soleprint
# Build with room config
python build.py --cfg amar
cd gen/amar && .venv/bin/python run.py
# Setup (once)
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Visit http://localhost:12000</pre
>
</div>
# Option 1: Build standalone (soleprint only)
python build.py --cfg standalone
cd gen/standalone && ./ctrl/start.sh
# Visit http://localhost:12000
# Option 2: Build managed room (soleprint + your app)
python build.py --cfg myroom
cd gen/myroom && ./ctrl/start.sh
# Visit http://myroom.spr.local.ar
# Option 3: Use the installer (coming soon)</pre
>
<p style="margin-top: 1rem">
<span class="lang-en">Minimal config example:</span>
<span class="lang-es">Ejemplo de config minima:</span>
</p>
<pre>
// cfg/myroom/config.json
{
"room": "myroom",
"artery": {
"veins": ["google", "jira", "slack"],
"shunts": ["mercadopago"]
},
"atlas": {
"books": ["gherkin", "feature-flow"]
},
"station": {
"tools": ["tester", "modelgen"],
"monitors": ["databrowse"]
}
}</pre
>
</section>
<section class="findings-section">
<h2>What Soleprint Solves</h2>
<h2>
<span class="lang-en">Architecture</span>
<span class="lang-es">Arquitectura</span>
</h2>
<p class="lang-en">Deep dive into how soleprint works.</p>
<p class="lang-es">Profundizando en como funciona soleprint.</p>
<div class="findings-grid">
<article class="finding-card">
<h3>Freelance Standardization</h3>
<p>Consistent framework across projects.</p>
</article>
<article class="finding-card">
<h3>Missing Infrastructure</h3>
<p>
Mock systems not ready yet - DBs, APIs, Kubernetes.
<a
href="architecture/"
class="finding-card"
style="text-decoration: none"
>
<h3>
<span class="lang-en">Architecture Diagrams →</span
><span class="lang-es"
>Diagramas de Arquitectura →</span
>
</h3>
<p class="lang-en">
System overview, artery hierarchy, build flow, room
configuration.
</p>
</article>
<article class="finding-card">
<h3>Reliable Testing</h3>
<p>BDD -> Gherkin -> Tests.</p>
</article>
<p class="lang-es">
Vista general, jerarquia de artery, flujo de build,
configuracion de rooms.
</p>
</a>
<a
href="architecture/sidebar-injection.html"
class="finding-card"
style="text-decoration: none"
>
<h3>
<span class="lang-en">Sidebar Injection →</span
><span class="lang-es">Inyeccion de Sidebar →</span>
</h3>
<p class="lang-en">
How the managed room sidebar works with nginx +
JavaScript.
</p>
<p class="lang-es">
Como funciona el sidebar del managed room con nginx
+ JavaScript.
</p>
</a>
</div>
</section>
</main>
<footer>
<p>Soleprint - Development Workflow Platform</p>
<p>
<a href="https://soleprint.mcrn.ar">soleprint.mcrn.ar</a> ·
<a href="https://sample.spr.mcrn.ar">Live Demo</a>
</p>
</footer>
</body>
</html>

54
docs/lang-toggle.js Normal file
View File

@@ -0,0 +1,54 @@
// Language toggle for soleprint docs
// Include this script and add: <div id="lang-toggle"></div> in header
(function () {
function setLang(lang) {
localStorage.setItem("spr-docs-lang", lang);
document.documentElement.lang = lang;
document.querySelectorAll(".lang-toggle button").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.lang === lang);
});
}
document.addEventListener("DOMContentLoaded", () => {
const currentLang = localStorage.getItem("spr-docs-lang") || "en";
// Inject toggle HTML
const container = document.getElementById("lang-toggle");
if (container) {
container.className = "lang-toggle";
const btnEn = document.createElement("button");
btnEn.textContent = "EN";
btnEn.dataset.lang = "en";
btnEn.addEventListener("click", () => setLang("en"));
const btnEs = document.createElement("button");
btnEs.textContent = "ES";
btnEs.dataset.lang = "es";
btnEs.addEventListener("click", () => setLang("es"));
container.appendChild(btnEn);
container.appendChild(btnEs);
}
setLang(currentLang);
});
// Inject styles
const style = document.createElement("style");
style.textContent = `
.lang-toggle { position: absolute; top: 1.5rem; right: 2rem; display: flex; border: 1px solid #888; border-radius: 4px; overflow: hidden; }
.lang-toggle button { background: #1a1a1a; border: none; color: #888; padding: 0.4rem 0.8rem; font-family: inherit; font-size: 0.75rem; cursor: pointer; }
.lang-toggle button:first-child { border-right: 1px solid #888; }
.lang-toggle button:hover { background: #0a0a0a; color: #fff; }
.lang-toggle button.active { background: var(--accent, #b91c1c); color: #fff; }
header { position: relative; }
.lang-en, .lang-es { display: none; }
html[lang="en"] .lang-en { display: block; }
html[lang="es"] .lang-es { display: block; }
html[lang="en"] span.lang-en { display: inline; }
html[lang="es"] span.lang-es { display: inline; }
`;
document.head.appendChild(style);
})();

336
docs/station/index.html Normal file
View File

@@ -0,0 +1,336 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Station - Soleprint</title>
<link rel="stylesheet" href="../architecture/styles.css" />
<style>
.composition {
background: #1a1a1a;
border: 2px solid #7c3aed;
padding: 1rem;
border-radius: 12px;
margin-bottom: 1rem;
}
.composition h3 {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
color: #c4b5fd;
}
.composition > p {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #a3a3a3;
}
.components {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.component {
background: #0a0a0a;
border: 1px solid #3f3f3f;
padding: 0.75rem;
border-radius: 8px;
}
.component h4 {
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
color: #c4b5fd;
}
.component p {
margin: 0;
font-size: 0.85rem;
color: #a3a3a3;
}
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
</style>
<script src="../lang-toggle.js"></script>
</head>
<body>
<header>
<div id="lang-toggle"></div>
<h1>Station</h1>
<p class="subtitle">
<span class="lang-en">Monitors, Environments & Tools</span
><span class="lang-es">Monitores, Entornos y Herramientas</span>
</p>
</header>
<main>
<section class="findings-section">
<h2>
<span class="lang-en">Model</span
><span class="lang-es">Modelo</span>
</h2>
<div class="model-grid">
<div class="composition">
<h3>Desk</h3>
<p class="lang-en">
Control center - collection of monitors
</p>
<p class="lang-es">
Centro de control - coleccion de monitores
</p>
<div class="components">
<div class="component">
<h4>Monitor</h4>
<p>Web UI</p>
</div>
<div class="component">
<h4>Monitor</h4>
<p>Web UI</p>
</div>
<div class="component">
<h4>...</h4>
<p></p>
</div>
</div>
</div>
<div class="composition">
<h3>Monitor</h3>
<p class="lang-en">
Web interface - always running, always watching
</p>
<p class="lang-es">
Interfaz web - siempre corriendo, siempre observando
</p>
<div class="components">
<div class="component">
<h4>Web UI</h4>
<p>Dashboard</p>
</div>
<div class="component">
<h4>Room</h4>
<p class="lang-en">Config/env</p>
<p class="lang-es">Config/entorno</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Data source</p>
<p class="lang-es">Fuente de datos</p>
</div>
</div>
</div>
<div class="composition">
<h3>Tool</h3>
<p class="lang-en">
CLI utility - run once, get results
</p>
<p class="lang-es">
Utilidad CLI - ejecutar una vez, obtener resultados
</p>
<div class="components">
<div class="component">
<h4>CLI</h4>
<p class="lang-en">Command interface</p>
<p class="lang-es">Interfaz de comandos</p>
</div>
<div class="component">
<h4>Room</h4>
<p class="lang-en">Config/env</p>
<p class="lang-es">Config/entorno</p>
</div>
<div class="component">
<h4>Depot</h4>
<p class="lang-en">Output storage</p>
<p class="lang-es">Almacenamiento de salida</p>
</div>
</div>
</div>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Architecture</span
><span class="lang-es">Arquitectura</span>
</h2>
<img
src="../architecture/01-system-overview.svg"
alt="System Overview"
style="
max-width: 100%;
background: white;
border-radius: 8px;
padding: 1rem;
"
/>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Components</span
><span class="lang-es">Componentes</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Desk</h3>
<p class="lang-en">
Collection of monitors. Your control center with all
the views you need.
</p>
<p class="lang-es">
Coleccion de monitores. Tu centro de control con
todas las vistas que necesitas.
</p>
</article>
<article class="finding-card">
<h3>Monitor</h3>
<p class="lang-en">
Web interfaces for observation. Data browsers,
dashboards, log viewers. Always running.
</p>
<p class="lang-es">
Interfaces web para observacion. Navegadores de
datos, dashboards, visores de logs. Siempre
corriendo.
</p>
</article>
<article class="finding-card">
<h3>Tool</h3>
<p class="lang-en">
CLI utilities and scripts. Code generators, test
runners, infra provisioners. Run once, get results.
</p>
<p class="lang-es">
Utilidades CLI y scripts. Generadores de codigo,
test runners, provisioners de infra. Ejecutar una
vez, obtener resultados.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Shared Components</span
><span class="lang-es">Componentes Compartidos</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Room</h3>
<p class="lang-en">
Runtime environment configuration. Tools and
monitors are configured per-room for isolation.
</p>
<p class="lang-es">
Configuracion del entorno. Tools y monitors se
configuran por room para aislamiento.
</p>
</article>
<article class="finding-card">
<h3>Depot</h3>
<p class="lang-en">
Data storage. For tools: output files, results. For
monitors: data to display.
</p>
<p class="lang-es">
Almacenamiento de datos. Para tools: archivos de
salida, resultados. Para monitors: datos a mostrar.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Available Tools</span
><span class="lang-es">Tools Disponibles</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>Tester</h3>
<p class="lang-en">
API and Playwright test runner. Discover tests, run
them, collect artifacts.
</p>
<p class="lang-es">
Test runner de API y Playwright. Descubrir tests,
ejecutarlos, recolectar artefactos.
</p>
</article>
<article class="finding-card">
<h3>ModelGen</h3>
<p class="lang-en">
Generate model diagrams from code. Introspect
soleprint structure, output SVG.
</p>
<p class="lang-es">
Generar diagramas de modelo desde codigo.
Introspeccionar estructura de soleprint, generar
SVG.
</p>
</article>
<article class="finding-card">
<h3>Infra</h3>
<p class="lang-en">
Infrastructure provisioners. AWS, GCP, DigitalOcean
deployment helpers.
</p>
<p class="lang-es">
Provisioners de infraestructura. Helpers de deploy
para AWS, GCP, DigitalOcean.
</p>
</article>
<article class="finding-card">
<h3>DataGen</h3>
<p class="lang-en">
Test data generation. Create realistic fake data for
development.
</p>
<p class="lang-es">
Generacion de datos de test. Crear datos falsos
realistas para desarrollo.
</p>
</article>
<article class="finding-card">
<h3>DataBrowse</h3>
<p class="lang-en">
Navigable data model graphs generated from existing
models.
</p>
<p class="lang-es">
Grafos de modelo de datos navegables generados desde
modelos existentes.
</p>
</article>
</div>
</section>
<section class="findings-section">
<h2>
<span class="lang-en">Available Monitors</span
><span class="lang-es">Monitors Disponibles</span>
</h2>
<div class="findings-grid">
<article class="finding-card">
<h3>DataBrowse</h3>
<p class="lang-en">
Navigable data model graphs generated from existing
models.
</p>
<p class="lang-es">
Grafos de modelo de datos navegables generados desde
modelos existentes.
</p>
</article>
</div>
</section>
</main>
<footer>
<p>
<a href="../"
><span class="lang-en">← Back to index</span
><span class="lang-es">← Volver al indice</span></a
>
</p>
</footer>
</body>
</html>

View File

@@ -17,4 +17,4 @@ COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["uvicorn", "run:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -419,6 +419,21 @@
<div class="component"><h4>Depot</h4></div>
</div>
</div>
<div class="composition" style="margin-top: 1rem">
<h3>Shunt</h3>
<div class="components">
<div class="component"><h4>Vein Interface</h4></div>
<div class="component"><h4>Mock Data</h4></div>
</div>
</div>
<div class="composition" style="margin-top: 1rem">
<h3>Plexus</h3>
<div class="components">
<div class="component"><h4>Backend</h4></div>
<div class="component"><h4>Frontend</h4></div>
<div class="component"><h4>Infra</h4></div>
</div>
</div>
</section>
<section>

0
soleprint/artery/shunts/__init__.py Normal file → Executable file
View File

0
soleprint/artery/shunts/example/README.md Normal file → Executable file
View File

0
soleprint/artery/shunts/example/depot/responses.json Normal file → Executable file
View File

0
soleprint/artery/shunts/example/main.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/.env.example Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/README.md Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/__init__.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/api/__init__.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/api/routes.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/core/__init__.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/core/config.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/main.py Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/requirements.txt Normal file → Executable file
View File

0
soleprint/artery/shunts/mercadopago/run.py Normal file → Executable file
View File

View File

View File

@@ -144,6 +144,9 @@ class GoogleOAuth:
"name": userinfo.get("name"),
"picture": userinfo.get("picture"),
"hd": userinfo.get("hd"), # Hosted domain (Google Workspace)
# Include tokens for storage
"access_token": credentials.token,
"refresh_token": credentials.refresh_token,
}
def refresh_access_token(self, refresh_token: str) -> dict:

View File

@@ -15,6 +15,7 @@ class AuthConfig(BaseModel):
enabled: bool = False
provider: str = "google" # Vein name to use for auth
allowed_domains: list[str] = [] # Empty = allow any domain
allowed_emails: list[str] = [] # Specific emails to allow
session_secret: str = "" # Required if enabled, can be "ENV:VAR_NAME"
session_timeout_hours: int = 24
login_redirect: str = "/"

View File

@@ -4,6 +4,7 @@ Authentication middleware for route protection.
Generic middleware, provider-agnostic.
"""
import os
from datetime import datetime
from starlette.middleware.base import BaseHTTPMiddleware
@@ -11,6 +12,10 @@ from starlette.responses import JSONResponse, RedirectResponse
from .config import AuthConfig
# Local dev bypass - set via environment variable only, can't be triggered remotely
AUTH_BYPASS = os.environ.get("AUTH_BYPASS", "").lower() == "true"
AUTH_BYPASS_USER = os.environ.get("AUTH_BYPASS_USER", "dev@local")
class AuthMiddleware(BaseHTTPMiddleware):
"""
@@ -31,6 +36,15 @@ class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
path = request.url.path
# Local dev bypass - auto-authenticate
if AUTH_BYPASS:
request.state.user = {
"email": AUTH_BYPASS_USER,
"name": "Dev User",
"domain": "local",
}
return await call_next(request)
# Check if route is public
if self._is_public(path):
return await call_next(request)
@@ -49,15 +63,20 @@ class AuthMiddleware(BaseHTTPMiddleware):
session.clear()
return self._unauthorized(request, "Session expired")
# Check domain restriction
# Check domain/email restriction
user_domain = session.get("domain")
if self.config.allowed_domains:
if not user_domain or user_domain not in self.config.allowed_domains:
session.clear()
return self._unauthorized(
request,
f"Access restricted to: {', '.join(self.config.allowed_domains)}",
)
email_allowed = user_email in self.config.allowed_emails
domain_allowed = user_domain and user_domain in self.config.allowed_domains
no_restrictions = (
not self.config.allowed_domains and not self.config.allowed_emails
)
if not (email_allowed or domain_allowed or no_restrictions):
session.clear()
return self._unauthorized(
request,
f"Access restricted",
)
# Attach user to request state for downstream use
request.state.user = {

View File

@@ -111,15 +111,18 @@ async def callback(
except httpx.RequestError as e:
raise HTTPException(500, f"Failed to contact provider: {e}")
# Verify domain if restricted
# Verify domain/email restriction
user_email = user_info.get("email")
user_domain = user_info.get("hd")
if auth_config.allowed_domains:
if not user_domain or user_domain not in auth_config.allowed_domains:
raise HTTPException(
403,
f"Access restricted to: {', '.join(auth_config.allowed_domains)}. "
f"Your account is from: {user_domain or 'personal Gmail'}",
)
email_allowed = user_email in auth_config.allowed_emails
domain_allowed = user_domain and user_domain in auth_config.allowed_domains
no_restrictions = not auth_config.allowed_domains and not auth_config.allowed_emails
if not (email_allowed or domain_allowed or no_restrictions):
raise HTTPException(
403,
f"Access restricted. Your account ({user_email}) is not authorized.",
)
# Create session
expires_at = datetime.now() + timedelta(hours=auth_config.session_timeout_hours)

View File

@@ -214,6 +214,36 @@
font-size: 0.85rem;
color: #666;
}
.showcase-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
}
.showcase-link {
display: inline-block;
background: linear-gradient(135deg, #d4a574, #b8956a);
color: #0a0a0a;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
transition: transform 0.15s, box-shadow 0.15s;
}
.showcase-link:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(212, 165, 116, 0.3);
}
.showcase-hint {
color: #666;
font-size: 0.8rem;
text-decoration: none;
}
.showcase-hint:hover {
color: #d4a574;
}
</style>
{% if managed %}
<link rel="stylesheet" href="/sidebar.css">
@@ -244,6 +274,14 @@
</header>
<p class="tagline">Cada paso deja huella</p>
{% if showcase_url %}
<div class="showcase-container">
<a href="{{ showcase_url }}" class="showcase-link">Managed Room Demo</a>
<a href="/artery" class="showcase-hint">what's a room?</a>
<a href="https://mariano.mcrn.ar/docs/soleprint/" class="showcase-hint">see docs</a>
</div>
{% endif %}
<p class="mission" style="display: none">
<!-- placeholder for session alerts -->
</p>

View File

@@ -351,6 +351,10 @@ def artery_index(request: Request):
from jinja2 import Template
# Load rooms and depots
rooms = load_data("rooms.json") or []
depots = load_data("depots.json") or []
template = Template(html_path.read_text())
return HTMLResponse(
template.render(
@@ -361,6 +365,8 @@ def artery_index(request: Request):
pulses=pulses,
shunts=shunts,
plexuses=plexuses,
rooms=rooms,
depots=depots,
soleprint_url="/",
)
)
@@ -601,6 +607,7 @@ def index(request: Request):
config = load_config()
managed = config.get("managed", {})
managed_url = get_managed_url(request, managed)
showcase_url = config.get("showcase_url")
return templates.TemplateResponse(
"index.html",
@@ -611,6 +618,7 @@ def index(request: Request):
"station": "/station",
"managed": managed,
"managed_url": managed_url,
"showcase_url": showcase_url,
},
)

View File

@@ -4,24 +4,38 @@ Modelgen - Generic Model Generation Tool
Generates typed models from various sources to various output formats.
Input sources:
- Configuration files (soleprint.config.json style)
- JSON Schema (planned)
- Existing codebases: Django, SQLAlchemy, Prisma (planned - for databrowse)
- Configuration files (soleprint config.json style)
- Python dataclasses in schema/ folder
- Existing codebases: Django, SQLAlchemy, Prisma (for extraction)
Output formats:
- pydantic: Pydantic BaseModel classes
- django: Django ORM models (planned)
- prisma: Prisma schema (planned)
- sqlalchemy: SQLAlchemy models (planned)
- django: Django ORM models
- typescript: TypeScript interfaces
- protobuf: Protocol Buffer definitions
- prisma: Prisma schema
Usage:
python -m station.tools.modelgen from-config -c config.json -o models.py -f pydantic
python -m station.tools.modelgen list-formats
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
python -m soleprint.station.tools.modelgen list-formats
"""
__version__ = "0.1.0"
__version__ = "0.2.0"
from .config_loader import ConfigLoader, load_config
from .model_generator import WRITERS, ModelGenerator
from .generator import GENERATORS, BaseGenerator
from .loader import ConfigLoader, load_config
from .model_generator import ModelGenerator
__all__ = ["ModelGenerator", "ConfigLoader", "load_config", "WRITERS"]
# Backwards compatibility
WRITERS = GENERATORS
__all__ = [
"ModelGenerator",
"ConfigLoader",
"load_config",
"GENERATORS",
"WRITERS",
"BaseGenerator",
]

View File

@@ -4,34 +4,34 @@ Modelgen - Generic Model Generation Tool
Generates typed models from various sources to various formats.
Input sources:
- Configuration files (soleprint.config.json style)
- JSON Schema (planned)
- Existing codebases: Django, SQLAlchemy, Prisma (planned - for databrowse)
- from-config: Configuration files (soleprint config.json style)
- from-schema: Python dataclasses in schema/ folder
- extract: Existing codebases (Django, SQLAlchemy, Prisma)
Output formats:
- pydantic: Pydantic BaseModel classes
- django: Django ORM models (planned)
- prisma: Prisma schema (planned)
- sqlalchemy: SQLAlchemy models (planned)
- django: Django ORM models
- typescript: TypeScript interfaces
- protobuf: Protocol Buffer definitions
- prisma: Prisma schema
Usage:
python -m station.tools.modelgen --help
python -m station.tools.modelgen from-config -c config.json -o models/ -f pydantic
python -m station.tools.modelgen from-schema -s schema.json -o models/ -f pydantic
python -m station.tools.modelgen extract -s /path/to/django/app -o models/ -f pydantic
This is a GENERIC tool. For soleprint-specific builds, use:
python build.py dev|deploy
python -m soleprint.station.tools.modelgen --help
python -m soleprint.station.tools.modelgen from-config -c config.json -o models.py
python -m soleprint.station.tools.modelgen from-schema -o models/ --targets pydantic,typescript
python -m soleprint.station.tools.modelgen extract --source /path/to/django --targets pydantic
"""
import argparse
import sys
from pathlib import Path
from .generator import GENERATORS
def cmd_from_config(args):
"""Generate models from a configuration file (soleprint.config.json style)."""
from .config_loader import load_config
"""Generate models from a configuration file (soleprint config.json style)."""
from .loader import load_config
from .model_generator import ModelGenerator
config_path = Path(args.config)
@@ -52,35 +52,121 @@ def cmd_from_config(args):
)
result_path = generator.generate()
print(f"Models generated: {result_path}")
print(f"Models generated: {result_path}")
def cmd_from_schema(args):
"""Generate models from JSON Schema."""
print("Error: from-schema not yet implemented", file=sys.stderr)
print("Use from-config with a soleprint.config.json file for now", file=sys.stderr)
sys.exit(1)
"""Generate models from Python dataclasses in schema/ folder."""
from .loader import load_schema
from .writer import write_file
# Determine schema path
schema_path = Path(args.schema) if args.schema else Path.cwd() / "schema"
if not schema_path.exists():
print(f"Error: Schema folder not found: {schema_path}", file=sys.stderr)
print(
"Create a schema/ folder with Python dataclasses and an __init__.py",
file=sys.stderr,
)
print("that exports DATACLASSES and ENUMS lists.", file=sys.stderr)
sys.exit(1)
print(f"Loading schema: {schema_path}")
schema = load_schema(schema_path)
print(f"Found {len(schema.models)} models, {len(schema.enums)} enums")
# Parse targets
targets = [t.strip() for t in args.targets.split(",")]
output_dir = Path(args.output)
for target in targets:
if target not in GENERATORS:
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
continue
generator = GENERATORS[target]()
ext = generator.file_extension()
# Determine output filename (use target name to avoid overwrites)
if len(targets) == 1 and args.output.endswith(ext):
output_file = output_dir
else:
output_file = output_dir / f"models_{target}{ext}"
print(f"Generating {target} to: {output_file}")
generator.generate(schema, output_file)
print("Done!")
def cmd_extract(args):
"""Extract models from existing codebase (for databrowse graphs)."""
print("Error: extract not yet implemented", file=sys.stderr)
print(
"This will extract models from Django/SQLAlchemy/Prisma codebases.",
file=sys.stderr,
)
print("Use cases:", file=sys.stderr)
print(" - Generate browsable graphs for databrowse tool", file=sys.stderr)
print(" - Convert between ORM formats", file=sys.stderr)
sys.exit(1)
"""Extract models from existing codebase."""
from .loader.extract import EXTRACTORS
source_path = Path(args.source)
if not source_path.exists():
print(f"Error: Source path not found: {source_path}", file=sys.stderr)
sys.exit(1)
# Auto-detect or use specified framework
framework = args.framework
extractor = None
if framework == "auto":
for name, extractor_cls in EXTRACTORS.items():
ext = extractor_cls(source_path)
if ext.detect():
framework = name
extractor = ext
print(f"Detected framework: {framework}")
break
if not extractor:
print("Error: Could not auto-detect framework", file=sys.stderr)
print(f"Available frameworks: {list(EXTRACTORS.keys())}", file=sys.stderr)
sys.exit(1)
else:
if framework not in EXTRACTORS:
print(f"Error: Unknown framework: {framework}", file=sys.stderr)
print(f"Available: {list(EXTRACTORS.keys())}", file=sys.stderr)
sys.exit(1)
extractor = EXTRACTORS[framework](source_path)
print(f"Extracting from: {source_path}")
models, enums = extractor.extract()
print(f"Extracted {len(models)} models, {len(enums)} enums")
# Parse targets
targets = [t.strip() for t in args.targets.split(",")]
output_dir = Path(args.output)
for target in targets:
if target not in GENERATORS:
print(f"Warning: Unknown target '{target}', skipping", file=sys.stderr)
continue
generator = GENERATORS[target]()
ext = generator.file_extension()
# Determine output filename (use target name to avoid overwrites)
if len(targets) == 1 and args.output.endswith(ext):
output_file = output_dir
else:
output_file = output_dir / f"models_{target}{ext}"
print(f"Generating {target} to: {output_file}")
generator.generate((models, enums), output_file)
print("Done!")
def cmd_list_formats(args):
"""List available output formats."""
from .model_generator import ModelGenerator
print("Available output formats:")
for fmt in ModelGenerator.available_formats():
for fmt in GENERATORS.keys():
print(f" - {fmt}")
@@ -88,22 +174,25 @@ def main():
parser = argparse.ArgumentParser(
description="Modelgen - Generic Model Generation Tool",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
subparsers = parser.add_subparsers(dest="command", required=True)
# Available formats for help text
formats = list(GENERATORS.keys())
formats_str = ", ".join(formats)
# from-config command
config_parser = subparsers.add_parser(
"from-config",
help="Generate models from configuration file",
help="Generate models from soleprint configuration file",
)
config_parser.add_argument(
"--config",
"-c",
type=str,
required=True,
help="Path to configuration file (e.g., soleprint.config.json)",
help="Path to configuration file (e.g., config.json)",
)
config_parser.add_argument(
"--output",
@@ -117,22 +206,22 @@ def main():
"-f",
type=str,
default="pydantic",
choices=["pydantic", "django", "prisma", "sqlalchemy"],
choices=["pydantic"], # Only pydantic for config mode
help="Output format (default: pydantic)",
)
config_parser.set_defaults(func=cmd_from_config)
# from-schema command (placeholder)
# from-schema command
schema_parser = subparsers.add_parser(
"from-schema",
help="Generate models from JSON Schema (not yet implemented)",
help="Generate models from Python dataclasses in schema/ folder",
)
schema_parser.add_argument(
"--schema",
"-s",
type=str,
required=True,
help="Path to JSON Schema file",
default=None,
help="Path to schema folder (default: ./schema)",
)
schema_parser.add_argument(
"--output",
@@ -142,19 +231,18 @@ def main():
help="Output path (file or directory)",
)
schema_parser.add_argument(
"--format",
"-f",
"--targets",
"-t",
type=str,
default="pydantic",
choices=["pydantic", "django", "prisma", "sqlalchemy"],
help="Output format (default: pydantic)",
help=f"Comma-separated output targets ({formats_str})",
)
schema_parser.set_defaults(func=cmd_from_schema)
# extract command (placeholder for databrowse)
# extract command
extract_parser = subparsers.add_parser(
"extract",
help="Extract models from existing codebase (not yet implemented)",
help="Extract models from existing codebase",
)
extract_parser.add_argument(
"--source",
@@ -165,10 +253,11 @@ def main():
)
extract_parser.add_argument(
"--framework",
"-f",
type=str,
choices=["django", "sqlalchemy", "prisma", "auto"],
default="auto",
help="Source framework to extract from (default: auto-detect)",
help="Source framework (default: auto-detect)",
)
extract_parser.add_argument(
"--output",
@@ -178,12 +267,11 @@ def main():
help="Output path (file or directory)",
)
extract_parser.add_argument(
"--format",
"-f",
"--targets",
"-t",
type=str,
default="pydantic",
choices=["pydantic", "django", "prisma", "sqlalchemy"],
help="Output format (default: pydantic)",
help=f"Comma-separated output targets ({formats_str})",
)
extract_parser.set_defaults(func=cmd_extract)

View File

@@ -0,0 +1,40 @@
"""
Generator - Stack-specific code generators for modelgen.
Supported generators:
- PydanticGenerator: Pydantic BaseModel classes
- DjangoGenerator: Django ORM models
- TypeScriptGenerator: TypeScript interfaces
- ProtobufGenerator: Protocol Buffer definitions
- PrismaGenerator: Prisma schema
"""
from typing import Dict, Type
from .base import BaseGenerator
from .django import DjangoGenerator
from .prisma import PrismaGenerator
from .protobuf import ProtobufGenerator
from .pydantic import PydanticGenerator
from .typescript import TypeScriptGenerator
# Registry of available generators
GENERATORS: Dict[str, Type[BaseGenerator]] = {
"pydantic": PydanticGenerator,
"django": DjangoGenerator,
"typescript": TypeScriptGenerator,
"ts": TypeScriptGenerator, # Alias
"protobuf": ProtobufGenerator,
"proto": ProtobufGenerator, # Alias
"prisma": PrismaGenerator,
}
__all__ = [
"BaseGenerator",
"PydanticGenerator",
"DjangoGenerator",
"TypeScriptGenerator",
"ProtobufGenerator",
"PrismaGenerator",
"GENERATORS",
]

View File

@@ -0,0 +1,23 @@
"""
Base Generator
Abstract base class for all code generators.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any
class BaseGenerator(ABC):
"""Abstract base for code generators."""
@abstractmethod
def generate(self, models: Any, output_path: Path) -> None:
"""Generate code for the given models to the specified path."""
pass
@abstractmethod
def file_extension(self) -> str:
"""Return the file extension for this format."""
pass

View File

@@ -0,0 +1,268 @@
"""
Django Generator
Generates Django ORM models from model definitions.
"""
import dataclasses as dc
from enum import Enum
from pathlib import Path
from typing import Any, List, get_type_hints
from ..helpers import format_opts, get_origin_name, get_type_name, unwrap_optional
from ..loader.schema import EnumDefinition, ModelDefinition
from ..types import DJANGO_SPECIAL, DJANGO_TYPES
from .base import BaseGenerator
class DjangoGenerator(BaseGenerator):
"""Generates Django ORM model files."""
def file_extension(self) -> str:
return ".py"
def generate(self, models, output_path: Path) -> None:
"""Generate Django models to output_path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Handle different input types
if hasattr(models, "models"):
# SchemaLoader or similar
content = self._generate_from_definitions(
models.models, getattr(models, "enums", [])
)
elif isinstance(models, tuple):
# (models, enums) tuple
content = self._generate_from_definitions(models[0], models[1])
elif isinstance(models, list):
# List of dataclasses (MPR style)
content = self._generate_from_dataclasses(models)
else:
raise ValueError(f"Unsupported input type: {type(models)}")
output_path.write_text(content)
def _generate_from_definitions(
self, models: List[ModelDefinition], enums: List[EnumDefinition]
) -> str:
"""Generate from ModelDefinition objects."""
lines = self._generate_header()
# Generate enums as TextChoices
for enum_def in enums:
lines.extend(self._generate_text_choices(enum_def))
lines.append("")
# Generate models
for model_def in models:
lines.extend(self._generate_model_from_definition(model_def))
lines.extend(["", ""])
return "\n".join(lines)
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
"""Generate from Python dataclasses (MPR style)."""
lines = self._generate_header()
for cls in dataclasses:
lines.extend(self._generate_model_from_dataclass(cls))
lines.extend(["", ""])
return "\n".join(lines)
def _generate_header(self) -> List[str]:
"""Generate file header."""
return [
'"""',
"Django ORM Models - GENERATED FILE",
"",
"Do not edit directly. Regenerate using modelgen.",
'"""',
"",
"import uuid",
"from django.db import models",
"",
]
def _generate_text_choices(self, enum_def: EnumDefinition) -> List[str]:
"""Generate Django TextChoices from EnumDefinition."""
lines = [
f"class {enum_def.name}(models.TextChoices):",
]
for name, value in enum_def.values:
label = name.replace("_", " ").title()
lines.append(f' {name} = "{value}", "{label}"')
return lines
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
"""Generate Django model from ModelDefinition."""
docstring = model_def.docstring or model_def.name
lines = [
f"class {model_def.name}(models.Model):",
f' """{docstring.strip().split(chr(10))[0]}"""',
"",
]
for field in model_def.fields:
django_field = self._resolve_field_type(
field.name, field.type_hint, field.default, field.optional
)
lines.append(f" {field.name} = {django_field}")
# Add Meta and __str__
lines.extend(
[
"",
" class Meta:",
' ordering = ["-created_at"]'
if any(f.name == "created_at" for f in model_def.fields)
else " pass",
"",
" def __str__(self):",
]
)
# Determine __str__ return
field_names = [f.name for f in model_def.fields]
if "filename" in field_names:
lines.append(" return self.filename")
elif "name" in field_names:
lines.append(" return self.name")
else:
lines.append(" return str(self.id)")
return lines
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
"""Generate Django model from a dataclass (MPR style)."""
docstring = cls.__doc__ or cls.__name__
lines = [
f"class {cls.__name__}(models.Model):",
f' """{docstring.strip().split(chr(10))[0]}"""',
"",
]
hints = get_type_hints(cls)
fields = {f.name: f for f in dc.fields(cls)}
# Check for enums and add Status inner class if needed
for type_hint in hints.values():
base, _ = unwrap_optional(type_hint)
if isinstance(base, type) and issubclass(base, Enum):
lines.append(" class Status(models.TextChoices):")
for member in base:
label = member.name.replace("_", " ").title()
lines.append(f' {member.name} = "{member.value}", "{label}"')
lines.append("")
break
# Generate fields
for name, type_hint in hints.items():
if name.startswith("_"):
continue
field = fields.get(name)
default = dc.MISSING
if field and field.default is not dc.MISSING:
default = field.default
django_field = self._resolve_field_type(name, type_hint, default, False)
lines.append(f" {name} = {django_field}")
# Add Meta and __str__
lines.extend(
[
"",
" class Meta:",
' ordering = ["-created_at"]'
if "created_at" in hints
else " pass",
"",
" def __str__(self):",
]
)
if "filename" in hints:
lines.append(" return self.filename")
elif "name" in hints:
lines.append(" return self.name")
else:
lines.append(" return str(self.id)")
return lines
def _resolve_field_type(
self, name: str, type_hint: Any, default: Any, optional: bool
) -> str:
"""Resolve Python type to Django field."""
# Special fields
if name in DJANGO_SPECIAL:
return DJANGO_SPECIAL[name]
base, is_optional = unwrap_optional(type_hint)
optional = optional or is_optional
origin = get_origin_name(base)
type_name = get_type_name(base)
opts = format_opts(optional)
# Container types
if origin == "dict":
return DJANGO_TYPES["dict"]
if origin == "list":
return DJANGO_TYPES["list"]
# UUID / datetime
if type_name == "UUID":
return DJANGO_TYPES["UUID"].format(opts=opts)
if type_name == "datetime":
return DJANGO_TYPES["datetime"].format(opts=opts)
# Enum
if isinstance(base, type) and issubclass(base, Enum):
extra = []
if optional:
extra.append("null=True, blank=True")
if default is not dc.MISSING and isinstance(default, Enum):
extra.append(f"default=Status.{default.name}")
return DJANGO_TYPES["enum"].format(
opts=", " + ", ".join(extra) if extra else ""
)
# Text fields (based on name heuristics)
if base is str and any(
x in name for x in ("message", "comments", "description")
):
return DJANGO_TYPES["text"]
# BigInt fields
if base is int and name in ("file_size", "bitrate"):
return DJANGO_TYPES["bigint"].format(opts=opts)
# String with max_length
if base is str:
max_length = 1000 if "path" in name else 500 if "filename" in name else 255
return DJANGO_TYPES[str].format(
max_length=max_length, opts=", " + opts if opts else ""
)
# Integer
if base is int:
extra = [opts] if opts else []
if default is not dc.MISSING and not callable(default):
extra.append(f"default={default}")
return DJANGO_TYPES[int].format(opts=", ".join(extra))
# Float
if base is float:
extra = [opts] if opts else []
if default is not dc.MISSING and not callable(default):
extra.append(f"default={default}")
return DJANGO_TYPES[float].format(opts=", ".join(extra))
# Boolean
if base is bool:
default_val = default if default is not dc.MISSING else False
return DJANGO_TYPES[bool].format(default=default_val)
# Fallback to CharField
return DJANGO_TYPES[str].format(
max_length=255, opts=", " + opts if opts else ""
)

View File

@@ -0,0 +1,173 @@
"""
Prisma Generator
Generates Prisma schema from model definitions.
"""
from enum import Enum
from pathlib import Path
from typing import Any, List, get_type_hints
from ..helpers import get_origin_name, get_type_name, unwrap_optional
from ..loader.schema import EnumDefinition, ModelDefinition
from ..types import PRISMA_SPECIAL, PRISMA_TYPES
from .base import BaseGenerator
class PrismaGenerator(BaseGenerator):
"""Generates Prisma schema files."""
def file_extension(self) -> str:
return ".prisma"
def generate(self, models, output_path: Path) -> None:
"""Generate Prisma schema to output_path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Handle different input types
if hasattr(models, "models"):
# SchemaLoader
content = self._generate_from_definitions(
models.models, getattr(models, "enums", [])
)
elif isinstance(models, tuple):
# (models, enums) tuple
content = self._generate_from_definitions(models[0], models[1])
elif isinstance(models, list):
# List of dataclasses (MPR style)
content = self._generate_from_dataclasses(models)
else:
raise ValueError(f"Unsupported input type: {type(models)}")
output_path.write_text(content)
def _generate_from_definitions(
self, models: List[ModelDefinition], enums: List[EnumDefinition]
) -> str:
"""Generate from ModelDefinition objects."""
lines = self._generate_header()
# Generate enums
for enum_def in enums:
lines.extend(self._generate_enum(enum_def))
lines.append("")
# Generate models
for model_def in models:
lines.extend(self._generate_model_from_definition(model_def))
lines.append("")
return "\n".join(lines)
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
"""Generate from Python dataclasses (MPR style)."""
lines = self._generate_header()
# Collect and generate enums first
enums_generated = set()
for cls in dataclasses:
hints = get_type_hints(cls)
for type_hint in hints.values():
base, _ = unwrap_optional(type_hint)
if isinstance(base, type) and issubclass(base, Enum):
if base.__name__ not in enums_generated:
lines.extend(self._generate_enum_from_python(base))
lines.append("")
enums_generated.add(base.__name__)
# Generate models
for cls in dataclasses:
lines.extend(self._generate_model_from_dataclass(cls))
lines.append("")
return "\n".join(lines)
def _generate_header(self) -> List[str]:
"""Generate file header with datasource and generator."""
return [
"// Prisma Schema - GENERATED FILE",
"//",
"// Do not edit directly. Regenerate using modelgen.",
"",
"generator client {",
' provider = "prisma-client-py"',
"}",
"",
"datasource db {",
' provider = "postgresql"',
' url = env("DATABASE_URL")',
"}",
"",
]
def _generate_enum(self, enum_def: EnumDefinition) -> List[str]:
"""Generate Prisma enum from EnumDefinition."""
lines = [f"enum {enum_def.name} {{"]
for name, _ in enum_def.values:
lines.append(f" {name}")
lines.append("}")
return lines
def _generate_enum_from_python(self, enum_cls: type) -> List[str]:
"""Generate Prisma enum from Python Enum."""
lines = [f"enum {enum_cls.__name__} {{"]
for member in enum_cls:
lines.append(f" {member.name}")
lines.append("}")
return lines
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
"""Generate Prisma model from ModelDefinition."""
lines = [f"model {model_def.name} {{"]
for field in model_def.fields:
prisma_type = self._resolve_type(
field.name, field.type_hint, field.optional
)
lines.append(f" {field.name} {prisma_type}")
lines.append("}")
return lines
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
"""Generate Prisma model from a dataclass."""
lines = [f"model {cls.__name__} {{"]
for name, type_hint in get_type_hints(cls).items():
if name.startswith("_"):
continue
prisma_type = self._resolve_type(name, type_hint, False)
lines.append(f" {name} {prisma_type}")
lines.append("}")
return lines
def _resolve_type(self, name: str, type_hint: Any, optional: bool) -> str:
"""Resolve Python type to Prisma type string."""
# Special fields
if name in PRISMA_SPECIAL:
return PRISMA_SPECIAL[name]
base, is_optional = unwrap_optional(type_hint)
optional = optional or is_optional
origin = get_origin_name(base)
type_name = get_type_name(base)
# Container types
if origin == "dict" or origin == "list":
result = PRISMA_TYPES.get(origin, "Json")
return f"{result}?" if optional else result
# UUID / datetime
if type_name in ("UUID", "datetime"):
result = PRISMA_TYPES.get(type_name, "String")
return f"{result}?" if optional else result
# Enum
if isinstance(base, type) and issubclass(base, Enum):
result = base.__name__
return f"{result}?" if optional else result
# Basic types
result = PRISMA_TYPES.get(base, "String")
return f"{result}?" if optional else result

View File

@@ -0,0 +1,168 @@
"""
Protobuf Generator
Generates Protocol Buffer definitions from model definitions.
"""
from pathlib import Path
from typing import Any, List, get_type_hints
from ..helpers import get_origin_name, unwrap_optional
from ..loader.schema import GrpcServiceDefinition, ModelDefinition
from ..types import PROTO_RESOLVERS
from .base import BaseGenerator
class ProtobufGenerator(BaseGenerator):
"""Generates Protocol Buffer definition files."""
def file_extension(self) -> str:
return ".proto"
def generate(self, models, output_path: Path) -> None:
"""Generate protobuf definitions to output_path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Handle different input types
if hasattr(models, "grpc_messages"):
# SchemaLoader with gRPC definitions
content = self._generate_from_loader(models)
elif isinstance(models, tuple) and len(models) >= 3:
# (messages, service_def) tuple
content = self._generate_from_definitions(models[0], models[1])
elif isinstance(models, list):
# List of dataclasses (MPR style)
content = self._generate_from_dataclasses(models)
else:
raise ValueError(f"Unsupported input type: {type(models)}")
output_path.write_text(content)
def _generate_from_loader(self, loader) -> str:
"""Generate from SchemaLoader."""
messages = loader.grpc_messages
service = loader.grpc_service
lines = self._generate_header(
service.package if service else "service",
service.name if service else "Service",
service.methods if service else [],
)
for model_def in messages:
lines.extend(self._generate_message_from_definition(model_def))
lines.append("")
return "\n".join(lines)
def _generate_from_definitions(
self, messages: List[ModelDefinition], service: GrpcServiceDefinition
) -> str:
"""Generate from ModelDefinition objects."""
lines = self._generate_header(service.package, service.name, service.methods)
for model_def in messages:
lines.extend(self._generate_message_from_definition(model_def))
lines.append("")
return "\n".join(lines)
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
"""Generate from Python dataclasses (MPR style)."""
lines = self._generate_header("service", "Service", [])
for cls in dataclasses:
lines.extend(self._generate_message_from_dataclass(cls))
lines.append("")
return "\n".join(lines)
def _generate_header(
self, package: str, service_name: str, methods: List[dict]
) -> List[str]:
"""Generate file header with service definition."""
lines = [
"// Protocol Buffer Definitions - GENERATED FILE",
"//",
"// Do not edit directly. Regenerate using modelgen.",
"",
'syntax = "proto3";',
"",
f"package {package};",
"",
]
if methods:
lines.append(f"service {service_name} {{")
for m in methods:
req = (
m["request"].__name__
if hasattr(m["request"], "__name__")
else str(m["request"])
)
resp = (
m["response"].__name__
if hasattr(m["response"], "__name__")
else str(m["response"])
)
returns = f"stream {resp}" if m.get("stream_response") else resp
lines.append(f" rpc {m['name']}({req}) returns ({returns});")
lines.extend(["}", ""])
return lines
def _generate_message_from_definition(
self, model_def: ModelDefinition
) -> List[str]:
"""Generate proto message from ModelDefinition."""
lines = [f"message {model_def.name} {{"]
if not model_def.fields:
lines.append(" // Empty")
else:
for i, field in enumerate(model_def.fields, 1):
proto_type, optional = self._resolve_type(field.type_hint)
prefix = (
"optional "
if optional and not proto_type.startswith("repeated")
else ""
)
lines.append(f" {prefix}{proto_type} {field.name} = {i};")
lines.append("}")
return lines
def _generate_message_from_dataclass(self, cls: type) -> List[str]:
"""Generate proto message from a dataclass."""
lines = [f"message {cls.__name__} {{"]
hints = get_type_hints(cls)
if not hints:
lines.append(" // Empty")
else:
for i, (name, type_hint) in enumerate(hints.items(), 1):
proto_type, optional = self._resolve_type(type_hint)
prefix = (
"optional "
if optional and not proto_type.startswith("repeated")
else ""
)
lines.append(f" {prefix}{proto_type} {name} = {i};")
lines.append("}")
return lines
def _resolve_type(self, type_hint: Any) -> tuple[str, bool]:
"""Resolve Python type to proto type. Returns (type, is_optional)."""
base, optional = unwrap_optional(type_hint)
origin = get_origin_name(base)
# Look up resolver
resolver = PROTO_RESOLVERS.get(origin) or PROTO_RESOLVERS.get(base)
if resolver:
result = resolver(base)
is_repeated = result.startswith("repeated")
return result, optional and not is_repeated
return "string", optional

View File

@@ -0,0 +1,427 @@
"""
Pydantic Generator
Generates Pydantic BaseModel classes from model definitions.
"""
from enum import Enum
from pathlib import Path
from typing import Any, List, get_type_hints
from ..helpers import get_origin_name, get_type_name, unwrap_optional
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
from ..types import PYDANTIC_RESOLVERS
from .base import BaseGenerator
class PydanticGenerator(BaseGenerator):
"""Generates Pydantic model files."""
def file_extension(self) -> str:
return ".py"
def generate(self, models, output_path: Path) -> None:
"""Generate Pydantic models to output_path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Detect input type and generate accordingly
if hasattr(models, "get_shared_component"):
# ConfigLoader (soleprint config)
content = self._generate_from_config(models)
elif hasattr(models, "models"):
# SchemaLoader
content = self._generate_from_definitions(
models.models, getattr(models, "enums", [])
)
elif isinstance(models, tuple):
# (models, enums) tuple from extractor
content = self._generate_from_definitions(models[0], models[1])
elif isinstance(models, list):
# List of dataclasses (MPR style)
content = self._generate_from_dataclasses(models)
else:
raise ValueError(f"Unsupported input type: {type(models)}")
output_path.write_text(content)
def _generate_from_definitions(
self, models: List[ModelDefinition], enums: List[EnumDefinition]
) -> str:
"""Generate from ModelDefinition objects (schema/extract mode)."""
lines = self._generate_header()
# Generate enums
for enum_def in enums:
lines.extend(self._generate_enum(enum_def))
lines.append("")
# Generate models
for model_def in models:
lines.extend(self._generate_model_from_definition(model_def))
lines.append("")
return "\n".join(lines)
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
"""Generate from Python dataclasses (MPR style)."""
lines = self._generate_header()
# Collect and generate enums first
enums_generated = set()
for cls in dataclasses:
hints = get_type_hints(cls)
for type_hint in hints.values():
base, _ = unwrap_optional(type_hint)
if isinstance(base, type) and issubclass(base, Enum):
if base.__name__ not in enums_generated:
lines.extend(self._generate_enum_from_python(base))
lines.append("")
enums_generated.add(base.__name__)
# Generate models
for cls in dataclasses:
lines.extend(self._generate_model_from_dataclass(cls))
lines.append("")
return "\n".join(lines)
def _generate_header(self) -> List[str]:
"""Generate file header."""
return [
'"""',
"Pydantic Models - GENERATED FILE",
"",
"Do not edit directly. Regenerate using modelgen.",
'"""',
"",
"from datetime import datetime",
"from enum import Enum",
"from typing import Any, Dict, List, Optional",
"from uuid import UUID",
"",
"from pydantic import BaseModel, Field",
"",
]
def _generate_enum(self, enum_def: EnumDefinition) -> List[str]:
"""Generate Pydantic enum from EnumDefinition."""
lines = [f"class {enum_def.name}(str, Enum):"]
for name, value in enum_def.values:
lines.append(f' {name} = "{value}"')
return lines
def _generate_enum_from_python(self, enum_cls: type) -> List[str]:
"""Generate Pydantic enum from Python Enum."""
lines = [f"class {enum_cls.__name__}(str, Enum):"]
for member in enum_cls:
lines.append(f' {member.name} = "{member.value}"')
return lines
def _generate_model_from_definition(self, model_def: ModelDefinition) -> List[str]:
"""Generate Pydantic model from ModelDefinition."""
docstring = model_def.docstring or model_def.name
lines = [
f"class {model_def.name}(BaseModel):",
f' """{docstring.strip().split(chr(10))[0]}"""',
]
if not model_def.fields:
lines.append(" pass")
else:
for field in model_def.fields:
py_type = self._resolve_type(field.type_hint, field.optional)
default = self._format_default(field.default, field.optional)
lines.append(f" {field.name}: {py_type}{default}")
return lines
def _generate_model_from_dataclass(self, cls: type) -> List[str]:
"""Generate Pydantic model from a dataclass."""
import dataclasses as dc
docstring = cls.__doc__ or cls.__name__
lines = [
f"class {cls.__name__}(BaseModel):",
f' """{docstring.strip().split(chr(10))[0]}"""',
]
hints = get_type_hints(cls)
fields = {f.name: f for f in dc.fields(cls)}
for name, type_hint in hints.items():
if name.startswith("_"):
continue
field = fields.get(name)
default_val = dc.MISSING
if field:
if field.default is not dc.MISSING:
default_val = field.default
py_type = self._resolve_type(type_hint, False)
default = self._format_default(default_val, "Optional" in py_type)
lines.append(f" {name}: {py_type}{default}")
return lines
def _resolve_type(self, type_hint: Any, optional: bool) -> str:
"""Resolve Python type to Pydantic type string."""
base, is_optional = unwrap_optional(type_hint)
optional = optional or is_optional
origin = get_origin_name(base)
type_name = get_type_name(base)
# Look up resolver
resolver = (
PYDANTIC_RESOLVERS.get(origin)
or PYDANTIC_RESOLVERS.get(type_name)
or PYDANTIC_RESOLVERS.get(base)
or (
PYDANTIC_RESOLVERS["enum"]
if isinstance(base, type) and issubclass(base, Enum)
else None
)
)
result = resolver(base) if resolver else "str"
return f"Optional[{result}]" if optional else result
def _format_default(self, default: Any, optional: bool) -> str:
"""Format default value for field."""
import dataclasses as dc
if optional:
return " = None"
if default is dc.MISSING or default is None:
return ""
if isinstance(default, str):
return f' = "{default}"'
if isinstance(default, Enum):
return f" = {default.__class__.__name__}.{default.name}"
if callable(default):
return " = Field(default_factory=list)" if "list" in str(default) else ""
return f" = {default!r}"
def _generate_from_config(self, config) -> str:
"""Generate from ConfigLoader (soleprint config.json mode)."""
# Get component names from config
config_comp = config.get_shared_component("config")
data_comp = config.get_shared_component("data")
data_flow_sys = config.get_system("data_flow")
doc_sys = config.get_system("documentation")
exec_sys = config.get_system("execution")
connector_comp = config.get_component("data_flow", "connector")
pulse_comp = config.get_component("data_flow", "composed")
pattern_comp = config.get_component("documentation", "pattern")
doc_composed = config.get_component("documentation", "composed")
tool_comp = config.get_component("execution", "utility")
monitor_comp = config.get_component("execution", "watcher")
cabinet_comp = config.get_component("execution", "container")
exec_composed = config.get_component("execution", "composed")
return f'''"""
Pydantic models - Generated from {config.framework.name}.config.json
DO NOT EDIT MANUALLY - Regenerate from config
"""
from enum import Enum
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class Status(str, Enum):
PENDING = "pending"
PLANNED = "planned"
BUILDING = "building"
DEV = "dev"
LIVE = "live"
READY = "ready"
class System(str, Enum):
{data_flow_sys.name.upper()} = "{data_flow_sys.name}"
{doc_sys.name.upper()} = "{doc_sys.name}"
{exec_sys.name.upper()} = "{exec_sys.name}"
class ToolType(str, Enum):
APP = "app"
CLI = "cli"
# === Shared Components ===
class {config_comp.title}(BaseModel):
"""{config_comp.description}. Shared across {data_flow_sys.name}, {exec_sys.name}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
config_path: Optional[str] = None
class {data_comp.title}(BaseModel):
"""{data_comp.description}. Shared across all systems."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
source_template: Optional[str] = None
data_path: Optional[str] = None
# === System-Specific Components ===
class {connector_comp.title}(BaseModel):
"""{connector_comp.description} ({data_flow_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
mock: Optional[bool] = None
description: Optional[str] = None
class {pattern_comp.title}(BaseModel):
"""{pattern_comp.description} ({doc_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
template_path: Optional[str] = None
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
class {tool_comp.title}(BaseModel):
"""{tool_comp.description} ({exec_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
type: Optional[ToolType] = None
description: Optional[str] = None
path: Optional[str] = None
url: Optional[str] = None
cli: Optional[str] = None
class {monitor_comp.title}(BaseModel):
"""{monitor_comp.description} ({exec_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
class {cabinet_comp.title}(BaseModel):
"""{cabinet_comp.description} ({exec_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
tools: List[{tool_comp.title}] = Field(default_factory=list)
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
# === Composed Types ===
class {pulse_comp.title}(BaseModel):
"""{pulse_comp.description} ({data_flow_sys.name}). Formula: {pulse_comp.formula}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
{connector_comp.name}: Optional[{connector_comp.title}] = None
{config_comp.name}: Optional[{config_comp.title}] = None
{data_comp.name}: Optional[{data_comp.title}] = None
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
class {doc_composed.title}(BaseModel):
"""{doc_composed.description} ({doc_sys.name}). Formula: {doc_composed.formula}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
template: Optional[{pattern_comp.title}] = None
{data_comp.name}: Optional[{data_comp.title}] = None
output_{data_comp.name}: Optional[{data_comp.title}] = None
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
class {exec_composed.title}(BaseModel):
"""{exec_composed.description} ({exec_sys.name}). Formula: {exec_composed.formula}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
cabinet: Optional[{cabinet_comp.title}] = None
{config_comp.name}: Optional[{config_comp.title}] = None
{data_comp.plural}: List[{data_comp.title}] = Field(default_factory=list)
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
# === Collection wrappers for JSON files ===
class {config_comp.title}Collection(BaseModel):
items: List[{config_comp.title}] = Field(default_factory=list)
class {data_comp.title}Collection(BaseModel):
items: List[{data_comp.title}] = Field(default_factory=list)
class {connector_comp.title}Collection(BaseModel):
items: List[{connector_comp.title}] = Field(default_factory=list)
class {pattern_comp.title}Collection(BaseModel):
items: List[{pattern_comp.title}] = Field(default_factory=list)
class {tool_comp.title}Collection(BaseModel):
items: List[{tool_comp.title}] = Field(default_factory=list)
class {monitor_comp.title}Collection(BaseModel):
items: List[{monitor_comp.title}] = Field(default_factory=list)
class {cabinet_comp.title}Collection(BaseModel):
items: List[{cabinet_comp.title}] = Field(default_factory=list)
class {pulse_comp.title}Collection(BaseModel):
items: List[{pulse_comp.title}] = Field(default_factory=list)
class {doc_composed.title}Collection(BaseModel):
items: List[{doc_composed.title}] = Field(default_factory=list)
class {exec_composed.title}Collection(BaseModel):
items: List[{exec_composed.title}] = Field(default_factory=list)
'''

View File

@@ -0,0 +1,144 @@
"""
TypeScript Generator
Generates TypeScript interfaces from model definitions.
"""
from enum import Enum
from pathlib import Path
from typing import Any, List, get_type_hints
from ..helpers import get_origin_name, get_type_name, unwrap_optional
from ..loader.schema import EnumDefinition, FieldDefinition, ModelDefinition
from ..types import TS_RESOLVERS
from .base import BaseGenerator
class TypeScriptGenerator(BaseGenerator):
"""Generates TypeScript interface files."""
def file_extension(self) -> str:
return ".ts"
def generate(self, models, output_path: Path) -> None:
"""Generate TypeScript types to output_path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
# Handle different input types
if hasattr(models, "models"):
# SchemaLoader
content = self._generate_from_definitions(
models.models, getattr(models, "enums", [])
)
elif isinstance(models, tuple):
# (models, enums) tuple
content = self._generate_from_definitions(models[0], models[1])
elif isinstance(models, list):
# List of dataclasses (MPR style)
content = self._generate_from_dataclasses(models)
else:
raise ValueError(f"Unsupported input type: {type(models)}")
output_path.write_text(content)
def _generate_from_definitions(
self, models: List[ModelDefinition], enums: List[EnumDefinition]
) -> str:
"""Generate from ModelDefinition objects."""
lines = self._generate_header()
# Generate enums as union types
for enum_def in enums:
values = " | ".join(f'"{v}"' for _, v in enum_def.values)
lines.append(f"export type {enum_def.name} = {values};")
lines.append("")
# Generate interfaces
for model_def in models:
lines.extend(self._generate_interface_from_definition(model_def))
lines.append("")
return "\n".join(lines)
def _generate_from_dataclasses(self, dataclasses: List[type]) -> str:
"""Generate from Python dataclasses (MPR style)."""
lines = self._generate_header()
# Collect and generate enums first
enums_generated = set()
for cls in dataclasses:
hints = get_type_hints(cls)
for type_hint in hints.values():
base, _ = unwrap_optional(type_hint)
if isinstance(base, type) and issubclass(base, Enum):
if base.__name__ not in enums_generated:
values = " | ".join(f'"{m.value}"' for m in base)
lines.append(f"export type {base.__name__} = {values};")
enums_generated.add(base.__name__)
lines.append("")
# Generate interfaces
for cls in dataclasses:
lines.extend(self._generate_interface_from_dataclass(cls))
lines.append("")
return "\n".join(lines)
def _generate_header(self) -> List[str]:
"""Generate file header."""
return [
"/**",
" * TypeScript Types - GENERATED FILE",
" *",
" * Do not edit directly. Regenerate using modelgen.",
" */",
"",
]
def _generate_interface_from_definition(
self, model_def: ModelDefinition
) -> List[str]:
"""Generate TypeScript interface from ModelDefinition."""
lines = [f"export interface {model_def.name} {{"]
for field in model_def.fields:
ts_type = self._resolve_type(field.type_hint, field.optional)
lines.append(f" {field.name}: {ts_type};")
lines.append("}")
return lines
def _generate_interface_from_dataclass(self, cls: type) -> List[str]:
"""Generate TypeScript interface from a dataclass."""
lines = [f"export interface {cls.__name__} {{"]
for name, type_hint in get_type_hints(cls).items():
if name.startswith("_"):
continue
ts_type = self._resolve_type(type_hint, False)
lines.append(f" {name}: {ts_type};")
lines.append("}")
return lines
def _resolve_type(self, type_hint: Any, optional: bool) -> str:
"""Resolve Python type to TypeScript type string."""
base, is_optional = unwrap_optional(type_hint)
optional = optional or is_optional
origin = get_origin_name(base)
type_name = get_type_name(base)
# Look up resolver
resolver = (
TS_RESOLVERS.get(origin)
or TS_RESOLVERS.get(type_name)
or TS_RESOLVERS.get(base)
or (
TS_RESOLVERS["enum"]
if isinstance(base, type) and issubclass(base, Enum)
else None
)
)
result = resolver(base) if resolver else "string"
return f"{result} | null" if optional else result

View File

@@ -0,0 +1,72 @@
"""
Type Helpers
Utilities for type introspection and resolution.
Used by generators and loaders.
"""
import dataclasses as dc
from enum import Enum
from typing import Any, Union, get_args, get_origin
def unwrap_optional(type_hint: Any) -> tuple[Any, bool]:
"""Unwrap Optional[T] -> (T, True) or (T, False) if not optional."""
origin = get_origin(type_hint)
if origin is Union:
args = [a for a in get_args(type_hint) if a is not type(None)]
return (args[0] if args else str, True)
return (type_hint, False)
def get_origin_name(type_hint: Any) -> str | None:
"""Get origin type name: 'dict', 'list', or None."""
origin = get_origin(type_hint)
if origin is dict:
return "dict"
if origin is list:
return "list"
return None
def get_type_name(type_hint: Any) -> str | None:
"""Get type name for special types like UUID, datetime."""
if hasattr(type_hint, "__name__"):
return type_hint.__name__
return None
def get_list_inner(type_hint: Any) -> str:
"""Get inner type of List[T]."""
args = get_args(type_hint)
if args and args[0] in (str, int, float, bool):
return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]]
return "str"
def get_field_default(field: dc.Field) -> Any:
"""Get default value from dataclass field."""
if field.default is not dc.MISSING:
return field.default
return dc.MISSING
def format_opts(optional: bool, extra: list[str] | None = None) -> str:
"""Format field options string for Django."""
parts = []
if optional:
parts.append("null=True, blank=True")
if extra:
parts.extend(extra)
return ", ".join(parts)
def is_enum(type_hint: Any) -> bool:
"""Check if type is an Enum."""
base, _ = unwrap_optional(type_hint)
return isinstance(base, type) and issubclass(base, Enum)
def get_enum_values(enum_class: type) -> list[tuple[str, str]]:
"""Get list of (name, value) pairs from an Enum."""
return [(m.name, m.value) for m in enum_class]

View File

@@ -0,0 +1,37 @@
"""
Loader - Input source handlers for modelgen.
Supported loaders:
- ConfigLoader: Load from soleprint config.json
- SchemaLoader: Load from Python dataclasses in schema/ folder
- Extractors: Extract from existing codebases (Django, SQLAlchemy, Prisma)
"""
from .config import ConfigLoader, load_config
from .extract import EXTRACTORS, BaseExtractor, DjangoExtractor
from .schema import (
EnumDefinition,
FieldDefinition,
GrpcServiceDefinition,
ModelDefinition,
SchemaLoader,
load_schema,
)
__all__ = [
# Config loader
"ConfigLoader",
"load_config",
# Schema loader
"SchemaLoader",
"load_schema",
# Model definitions
"ModelDefinition",
"FieldDefinition",
"EnumDefinition",
"GrpcServiceDefinition",
# Extractors
"BaseExtractor",
"DjangoExtractor",
"EXTRACTORS",
]

View File

@@ -1,7 +1,7 @@
"""
Configuration Loader
Loads and validates framework configuration files.
Loads and validates framework configuration files (soleprint config.json style).
"""
import json
@@ -114,22 +114,3 @@ def load_config(config_path: str | Path) -> ConfigLoader:
"""Load and validate configuration file"""
loader = ConfigLoader(config_path)
return loader.load()
if __name__ == "__main__":
# Test with pawprint config
import sys
config_path = Path(__file__).parent.parent / "pawprint.config.json"
loader = load_config(config_path)
print(f"Framework: {loader.framework.name} v{loader.framework.version}")
print(f"Tagline: {loader.framework.tagline}")
print(f"\nSystems:")
for sys in loader.systems:
print(f" {sys.icon} {sys.title} ({sys.name}) - {sys.tagline}")
print(f"\nShared Components:")
for key, comp in loader.components["shared"].items():
print(f" {comp.name} - {comp.description}")

View File

@@ -0,0 +1,20 @@
"""
Extractors - Extract model definitions from existing codebases.
Supported frameworks:
- Django: Extract from Django ORM models
- SQLAlchemy: Extract from SQLAlchemy models (planned)
- Prisma: Extract from Prisma schema (planned)
"""
from typing import Dict, Type
from .base import BaseExtractor
from .django import DjangoExtractor
# Registry of available extractors
EXTRACTORS: Dict[str, Type[BaseExtractor]] = {
"django": DjangoExtractor,
}
__all__ = ["BaseExtractor", "DjangoExtractor", "EXTRACTORS"]

View File

@@ -0,0 +1,38 @@
"""
Base Extractor
Abstract base class for model extractors.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import List
from ..schema import EnumDefinition, ModelDefinition
class BaseExtractor(ABC):
"""Abstract base for codebase model extractors."""
def __init__(self, source_path: Path):
self.source_path = Path(source_path)
@abstractmethod
def extract(self) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
"""
Extract model definitions from source codebase.
Returns:
Tuple of (models, enums)
"""
pass
@abstractmethod
def detect(self) -> bool:
"""
Detect if this extractor can handle the source path.
Returns:
True if this extractor can handle the source
"""
pass

View File

@@ -0,0 +1,237 @@
"""
Django Extractor
Extracts model definitions from Django ORM models.
"""
import ast
import sys
from pathlib import Path
from typing import Any, List, Optional
from ..schema import EnumDefinition, FieldDefinition, ModelDefinition
from .base import BaseExtractor
# Django field type mappings to Python types
DJANGO_FIELD_TYPES = {
"CharField": str,
"TextField": str,
"EmailField": str,
"URLField": str,
"SlugField": str,
"UUIDField": "UUID",
"IntegerField": int,
"BigIntegerField": "bigint",
"SmallIntegerField": int,
"PositiveIntegerField": int,
"FloatField": float,
"DecimalField": float,
"BooleanField": bool,
"NullBooleanField": bool,
"DateField": "datetime",
"DateTimeField": "datetime",
"TimeField": "datetime",
"JSONField": "dict",
"ForeignKey": "FK",
"OneToOneField": "FK",
"ManyToManyField": "M2M",
}
class DjangoExtractor(BaseExtractor):
"""Extracts models from Django ORM."""
def detect(self) -> bool:
"""Check if this is a Django project."""
# Look for manage.py or settings.py
manage_py = self.source_path / "manage.py"
settings_py = self.source_path / "settings.py"
if manage_py.exists():
return True
# Check for Django imports in any models.py
for models_file in self.source_path.rglob("models.py"):
content = models_file.read_text()
if "from django.db import models" in content:
return True
return settings_py.exists()
def extract(self) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
"""Extract Django models using AST parsing."""
models = []
enums = []
# Find all models.py files
for models_file in self.source_path.rglob("models.py"):
file_models, file_enums = self._extract_from_file(models_file)
models.extend(file_models)
enums.extend(file_enums)
return models, enums
def _extract_from_file(
self, file_path: Path
) -> tuple[List[ModelDefinition], List[EnumDefinition]]:
"""Extract models from a single models.py file."""
models = []
enums = []
content = file_path.read_text()
tree = ast.parse(content)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
# Check if it inherits from models.Model
if self._is_django_model(node):
model_def = self._parse_model_class(node)
if model_def:
models.append(model_def)
# Check if it's a TextChoices/IntegerChoices enum
elif self._is_django_choices(node):
enum_def = self._parse_choices_class(node)
if enum_def:
enums.append(enum_def)
return models, enums
def _is_django_model(self, node: ast.ClassDef) -> bool:
"""Check if class inherits from models.Model."""
for base in node.bases:
if isinstance(base, ast.Attribute):
if base.attr == "Model":
return True
elif isinstance(base, ast.Name):
if base.id in ("Model", "AbstractUser", "AbstractBaseUser"):
return True
return False
def _is_django_choices(self, node: ast.ClassDef) -> bool:
"""Check if class is a Django TextChoices/IntegerChoices."""
for base in node.bases:
if isinstance(base, ast.Attribute):
if base.attr in ("TextChoices", "IntegerChoices"):
return True
elif isinstance(base, ast.Name):
if base.id in ("TextChoices", "IntegerChoices"):
return True
return False
def _parse_model_class(self, node: ast.ClassDef) -> Optional[ModelDefinition]:
"""Parse a Django model class into ModelDefinition."""
fields = []
for item in node.body:
if isinstance(item, ast.Assign):
field_def = self._parse_field_assignment(item)
if field_def:
fields.append(field_def)
elif isinstance(item, ast.AnnAssign):
# Handle annotated assignments (Django 4.0+ style)
field_def = self._parse_annotated_field(item)
if field_def:
fields.append(field_def)
# Get docstring
docstring = ast.get_docstring(node)
return ModelDefinition(
name=node.name,
fields=fields,
docstring=docstring,
)
def _parse_field_assignment(self, node: ast.Assign) -> Optional[FieldDefinition]:
"""Parse a field assignment like: name = models.CharField(...)"""
if not node.targets or not isinstance(node.targets[0], ast.Name):
return None
field_name = node.targets[0].id
# Skip private fields and Meta class
if field_name.startswith("_") or field_name == "Meta":
return None
# Parse the field call
if isinstance(node.value, ast.Call):
return self._parse_field_call(field_name, node.value)
return None
def _parse_annotated_field(self, node: ast.AnnAssign) -> Optional[FieldDefinition]:
"""Parse an annotated field assignment."""
if not isinstance(node.target, ast.Name):
return None
field_name = node.target.id
if field_name.startswith("_"):
return None
if node.value and isinstance(node.value, ast.Call):
return self._parse_field_call(field_name, node.value)
return None
def _parse_field_call(
self, field_name: str, call: ast.Call
) -> Optional[FieldDefinition]:
"""Parse a Django field call like models.CharField(max_length=100)."""
# Get field type name
field_type_name = None
if isinstance(call.func, ast.Attribute):
field_type_name = call.func.attr
elif isinstance(call.func, ast.Name):
field_type_name = call.func.id
if not field_type_name:
return None
# Map to Python type
python_type = DJANGO_FIELD_TYPES.get(field_type_name, str)
# Check for null=True
optional = False
default = None
for keyword in call.keywords:
if keyword.arg == "null":
if isinstance(keyword.value, ast.Constant):
optional = keyword.value.value is True
elif keyword.arg == "default":
if isinstance(keyword.value, ast.Constant):
default = keyword.value.value
return FieldDefinition(
name=field_name,
type_hint=python_type,
default=default if default is not None else None,
optional=optional,
)
def _parse_choices_class(self, node: ast.ClassDef) -> Optional[EnumDefinition]:
"""Parse a Django TextChoices/IntegerChoices class."""
values = []
for item in node.body:
if isinstance(item, ast.Assign):
if item.targets and isinstance(item.targets[0], ast.Name):
name = item.targets[0].id
if name.isupper(): # Enum values are typically uppercase
# Get the value
value = name.lower() # Default to lowercase name
if isinstance(item.value, ast.Constant):
value = str(item.value.value)
elif isinstance(item.value, ast.Tuple) and item.value.elts:
# TextChoices: NAME = "value", "Label"
if isinstance(item.value.elts[0], ast.Constant):
value = str(item.value.elts[0].value)
values.append((name, value))
if not values:
return None
return EnumDefinition(name=node.name, values=values)

View File

@@ -0,0 +1,169 @@
"""
Schema Loader
Loads Python dataclasses from a schema/ folder.
Expects the folder to have an __init__.py that exports:
- DATACLASSES: List of dataclass types to generate
- ENUMS: List of Enum types to include
- GRPC_MESSAGES: (optional) List of gRPC message types
- GRPC_SERVICE: (optional) gRPC service definition dict
"""
import dataclasses as dc
import importlib.util
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Type, get_type_hints
@dataclass
class FieldDefinition:
"""Represents a model field."""
name: str
type_hint: Any
default: Any = dc.MISSING
optional: bool = False
@dataclass
class ModelDefinition:
"""Represents a model/dataclass."""
name: str
fields: List[FieldDefinition]
docstring: Optional[str] = None
@dataclass
class EnumDefinition:
"""Represents an enum."""
name: str
values: List[tuple[str, str]] # (name, value) pairs
@dataclass
class GrpcServiceDefinition:
"""Represents a gRPC service."""
package: str
name: str
methods: List[Dict[str, Any]]
class SchemaLoader:
"""Loads model definitions from Python dataclasses in schema/ folder."""
def __init__(self, schema_path: Path):
self.schema_path = Path(schema_path)
self.models: List[ModelDefinition] = []
self.enums: List[EnumDefinition] = []
self.grpc_messages: List[ModelDefinition] = []
self.grpc_service: Optional[GrpcServiceDefinition] = None
def load(self) -> "SchemaLoader":
"""Load schema definitions from the schema folder."""
init_path = self.schema_path / "__init__.py"
if not init_path.exists():
raise FileNotFoundError(f"Schema folder must have __init__.py: {init_path}")
# Import the schema module
module = self._import_module(init_path)
# Extract DATACLASSES
dataclasses = getattr(module, "DATACLASSES", [])
for cls in dataclasses:
self.models.append(self._parse_dataclass(cls))
# Extract ENUMS
enums = getattr(module, "ENUMS", [])
for enum_cls in enums:
self.enums.append(self._parse_enum(enum_cls))
# Extract GRPC_MESSAGES (optional)
grpc_messages = getattr(module, "GRPC_MESSAGES", [])
for cls in grpc_messages:
self.grpc_messages.append(self._parse_dataclass(cls))
# Extract GRPC_SERVICE (optional)
grpc_service = getattr(module, "GRPC_SERVICE", None)
if grpc_service:
self.grpc_service = GrpcServiceDefinition(
package=grpc_service.get("package", "service"),
name=grpc_service.get("name", "Service"),
methods=grpc_service.get("methods", []),
)
return self
def _import_module(self, path: Path):
"""Import a Python module from a file path."""
spec = importlib.util.spec_from_file_location("schema", path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load module from {path}")
module = importlib.util.module_from_spec(spec)
sys.modules["schema"] = module
spec.loader.exec_module(module)
return module
def _parse_dataclass(self, cls: Type) -> ModelDefinition:
"""Parse a dataclass into a ModelDefinition."""
hints = get_type_hints(cls)
fields_info = {f.name: f for f in dc.fields(cls)}
fields = []
for name, type_hint in hints.items():
if name.startswith("_"):
continue
field_info = fields_info.get(name)
default = dc.MISSING
if field_info:
if field_info.default is not dc.MISSING:
default = field_info.default
elif field_info.default_factory is not dc.MISSING:
default = field_info.default_factory
# Check if optional (Union with None)
optional = self._is_optional(type_hint)
fields.append(
FieldDefinition(
name=name,
type_hint=type_hint,
default=default,
optional=optional,
)
)
return ModelDefinition(
name=cls.__name__,
fields=fields,
docstring=cls.__doc__,
)
def _parse_enum(self, enum_cls: Type[Enum]) -> EnumDefinition:
"""Parse an Enum into an EnumDefinition."""
values = [(m.name, m.value) for m in enum_cls]
return EnumDefinition(name=enum_cls.__name__, values=values)
def _is_optional(self, type_hint: Any) -> bool:
"""Check if a type hint is Optional (Union with None)."""
from typing import Union, get_args, get_origin
origin = get_origin(type_hint)
if origin is Union:
args = get_args(type_hint)
return type(None) in args
return False
def load_schema(schema_path: str | Path) -> SchemaLoader:
"""Load schema definitions from folder."""
loader = SchemaLoader(schema_path)
return loader.load()

View File

@@ -1,314 +1,15 @@
"""
Model Generator
Generic model generation from configuration files.
Supports multiple output formats and is extensible for bidirectional conversion.
Output formats:
- pydantic: Pydantic BaseModel classes
- django: Django ORM models (planned)
- prisma: Prisma schema (planned)
- sqlalchemy: SQLAlchemy models (planned)
Future: Extract models FROM existing codebases (reverse direction)
Orchestrates model generation from various sources to various formats.
Delegates to loaders for input and generators for output.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Dict, Type
from .config_loader import ConfigLoader
class BaseModelWriter(ABC):
"""Abstract base for model output writers."""
@abstractmethod
def write(self, config: ConfigLoader, output_path: Path) -> None:
"""Write models to the specified path."""
pass
@abstractmethod
def file_extension(self) -> str:
"""Return the file extension for this format."""
pass
class PydanticWriter(BaseModelWriter):
"""Generates Pydantic model files."""
def file_extension(self) -> str:
return ".py"
def write(self, config: ConfigLoader, output_path: Path) -> None:
"""Write Pydantic models to output_path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
content = self._generate_content(config)
output_path.write_text(content)
def _generate_content(self, config: ConfigLoader) -> str:
"""Generate the Pydantic models file content."""
# Get component names from config
config_comp = config.get_shared_component("config")
data_comp = config.get_shared_component("data")
data_flow_sys = config.get_system("data_flow")
doc_sys = config.get_system("documentation")
exec_sys = config.get_system("execution")
connector_comp = config.get_component("data_flow", "connector")
pulse_comp = config.get_component("data_flow", "composed")
pattern_comp = config.get_component("documentation", "pattern")
doc_composed = config.get_component("documentation", "composed")
tool_comp = config.get_component("execution", "utility")
monitor_comp = config.get_component("execution", "watcher")
cabinet_comp = config.get_component("execution", "container")
exec_composed = config.get_component("execution", "composed")
return f'''"""
Pydantic models - Generated from {config.framework.name}.config.json
DO NOT EDIT MANUALLY - Regenerate from config
"""
from enum import Enum
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
class Status(str, Enum):
PENDING = "pending"
PLANNED = "planned"
BUILDING = "building"
DEV = "dev"
LIVE = "live"
READY = "ready"
class System(str, Enum):
{data_flow_sys.name.upper()} = "{data_flow_sys.name}"
{doc_sys.name.upper()} = "{doc_sys.name}"
{exec_sys.name.upper()} = "{exec_sys.name}"
class ToolType(str, Enum):
APP = "app"
CLI = "cli"
# === Shared Components ===
class {config_comp.title}(BaseModel):
"""{config_comp.description}. Shared across {data_flow_sys.name}, {exec_sys.name}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
config_path: Optional[str] = None
class {data_comp.title}(BaseModel):
"""{data_comp.description}. Shared across all systems."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
source_template: Optional[str] = None
data_path: Optional[str] = None
# === System-Specific Components ===
class {connector_comp.title}(BaseModel):
"""{connector_comp.description} ({data_flow_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
mock: Optional[bool] = None
description: Optional[str] = None
class {pattern_comp.title}(BaseModel):
"""{pattern_comp.description} ({doc_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
template_path: Optional[str] = None
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
class {tool_comp.title}(BaseModel):
"""{tool_comp.description} ({exec_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
type: Optional[ToolType] = None
description: Optional[str] = None
path: Optional[str] = None
url: Optional[str] = None
cli: Optional[str] = None
class {monitor_comp.title}(BaseModel):
"""{monitor_comp.description} ({exec_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
class {cabinet_comp.title}(BaseModel):
"""{cabinet_comp.description} ({exec_sys.name})."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
tools: List[{tool_comp.title}] = Field(default_factory=list)
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
# === Composed Types ===
class {pulse_comp.title}(BaseModel):
"""{pulse_comp.description} ({data_flow_sys.name}). Formula: {pulse_comp.formula}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
{connector_comp.name}: Optional[{connector_comp.title}] = None
{config_comp.name}: Optional[{config_comp.title}] = None
{data_comp.name}: Optional[{data_comp.title}] = None
system: Literal["{data_flow_sys.name}"] = "{data_flow_sys.name}"
class {doc_composed.title}(BaseModel):
"""{doc_composed.description} ({doc_sys.name}). Formula: {doc_composed.formula}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
template: Optional[{pattern_comp.title}] = None
{data_comp.name}: Optional[{data_comp.title}] = None
output_{data_comp.name}: Optional[{data_comp.title}] = None
system: Literal["{doc_sys.name}"] = "{doc_sys.name}"
class {exec_composed.title}(BaseModel):
"""{exec_composed.description} ({exec_sys.name}). Formula: {exec_composed.formula}."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
cabinet: Optional[{cabinet_comp.title}] = None
{config_comp.name}: Optional[{config_comp.title}] = None
{data_comp.plural}: List[{data_comp.title}] = Field(default_factory=list)
system: Literal["{exec_sys.name}"] = "{exec_sys.name}"
# === Collection wrappers for JSON files ===
class {config_comp.title}Collection(BaseModel):
items: List[{config_comp.title}] = Field(default_factory=list)
class {data_comp.title}Collection(BaseModel):
items: List[{data_comp.title}] = Field(default_factory=list)
class {connector_comp.title}Collection(BaseModel):
items: List[{connector_comp.title}] = Field(default_factory=list)
class {pattern_comp.title}Collection(BaseModel):
items: List[{pattern_comp.title}] = Field(default_factory=list)
class {tool_comp.title}Collection(BaseModel):
items: List[{tool_comp.title}] = Field(default_factory=list)
class {monitor_comp.title}Collection(BaseModel):
items: List[{monitor_comp.title}] = Field(default_factory=list)
class {cabinet_comp.title}Collection(BaseModel):
items: List[{cabinet_comp.title}] = Field(default_factory=list)
class {pulse_comp.title}Collection(BaseModel):
items: List[{pulse_comp.title}] = Field(default_factory=list)
class {doc_composed.title}Collection(BaseModel):
items: List[{doc_composed.title}] = Field(default_factory=list)
class {exec_composed.title}Collection(BaseModel):
items: List[{exec_composed.title}] = Field(default_factory=list)
'''
class DjangoWriter(BaseModelWriter):
"""Generates Django model files (placeholder)."""
def file_extension(self) -> str:
return ".py"
def write(self, config: ConfigLoader, output_path: Path) -> None:
raise NotImplementedError("Django model generation not yet implemented")
class PrismaWriter(BaseModelWriter):
"""Generates Prisma schema files (placeholder)."""
def file_extension(self) -> str:
return ".prisma"
def write(self, config: ConfigLoader, output_path: Path) -> None:
raise NotImplementedError("Prisma schema generation not yet implemented")
class SQLAlchemyWriter(BaseModelWriter):
"""Generates SQLAlchemy model files (placeholder)."""
def file_extension(self) -> str:
return ".py"
def write(self, config: ConfigLoader, output_path: Path) -> None:
raise NotImplementedError("SQLAlchemy model generation not yet implemented")
# Registry of available writers
WRITERS: Dict[str, Type[BaseModelWriter]] = {
"pydantic": PydanticWriter,
"django": DjangoWriter,
"prisma": PrismaWriter,
"sqlalchemy": SQLAlchemyWriter,
}
from .generator import GENERATORS, BaseGenerator
from .loader import ConfigLoader
class ModelGenerator:
@@ -316,7 +17,7 @@ class ModelGenerator:
Generates typed models from configuration.
This is the main entry point for model generation.
Delegates to format-specific writers.
Delegates to format-specific generators.
"""
def __init__(
@@ -331,19 +32,19 @@ class ModelGenerator:
Args:
config: Loaded configuration
output_path: Exact path where to write (file or directory depending on format)
output_format: Output format (pydantic, django, prisma, sqlalchemy)
output_format: Output format (pydantic, django, prisma, typescript, protobuf)
"""
self.config = config
self.output_path = Path(output_path)
self.output_format = output_format
if output_format not in WRITERS:
if output_format not in GENERATORS:
raise ValueError(
f"Unknown output format: {output_format}. "
f"Available: {list(WRITERS.keys())}"
f"Available: {list(GENERATORS.keys())}"
)
self.writer = WRITERS[output_format]()
self.generator = GENERATORS[output_format]()
def generate(self) -> Path:
"""
@@ -358,13 +59,19 @@ class ModelGenerator:
output_file = self.output_path
else:
# User specified a directory, add default filename
output_file = self.output_path / f"__init__{self.writer.file_extension()}"
output_file = (
self.output_path / f"__init__{self.generator.file_extension()}"
)
self.writer.write(self.config, output_file)
self.generator.generate(self.config, output_file)
print(f"Generated {self.output_format} models: {output_file}")
return output_file
@classmethod
def available_formats(cls) -> list:
"""Return list of available output formats."""
return list(WRITERS.keys())
return list(GENERATORS.keys())
# Re-export for backwards compatibility
WRITERS = GENERATORS

View File

@@ -0,0 +1,139 @@
"""
Type Dispatch Tables
Type mappings for each output format.
Used by generators to convert Python types to target framework types.
"""
from typing import Any, Callable, get_args
# =============================================================================
# Django Type Mappings
# =============================================================================
DJANGO_TYPES: dict[Any, str] = {
str: "models.CharField(max_length={max_length}{opts})",
int: "models.IntegerField({opts})",
float: "models.FloatField({opts})",
bool: "models.BooleanField(default={default})",
"UUID": "models.UUIDField({opts})",
"datetime": "models.DateTimeField({opts})",
"dict": "models.JSONField(default=dict, blank=True)",
"list": "models.JSONField(default=list, blank=True)",
"text": "models.TextField(blank=True, default='')",
"bigint": "models.BigIntegerField({opts})",
"enum": "models.CharField(max_length=20, choices=Status.choices{opts})",
}
DJANGO_SPECIAL: dict[str, str] = {
"id": "models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)",
"created_at": "models.DateTimeField(auto_now_add=True)",
"updated_at": "models.DateTimeField(auto_now=True)",
}
# =============================================================================
# Pydantic Type Resolvers
# =============================================================================
def _get_list_inner(type_hint: Any) -> str:
"""Get inner type of List[T] for Pydantic."""
args = get_args(type_hint)
if args and args[0] in (str, int, float, bool):
return {str: "str", int: "int", float: "float", bool: "bool"}[args[0]]
return "str"
PYDANTIC_RESOLVERS: dict[Any, Callable[[Any], str]] = {
str: lambda _: "str",
int: lambda _: "int",
float: lambda _: "float",
bool: lambda _: "bool",
"UUID": lambda _: "UUID",
"datetime": lambda _: "datetime",
"dict": lambda _: "Dict[str, Any]",
"list": lambda base: f"List[{_get_list_inner(base)}]",
"enum": lambda base: base.__name__,
}
# =============================================================================
# TypeScript Type Resolvers
# =============================================================================
def _resolve_ts_list(base: Any) -> str:
"""Resolve TypeScript list type."""
args = get_args(base)
if args:
inner = args[0]
if inner is str:
return "string[]"
elif inner is int or inner is float:
return "number[]"
elif inner is bool:
return "boolean[]"
return "string[]"
TS_RESOLVERS: dict[Any, Callable[[Any], str]] = {
str: lambda _: "string",
int: lambda _: "number",
float: lambda _: "number",
bool: lambda _: "boolean",
"UUID": lambda _: "string",
"datetime": lambda _: "string",
"dict": lambda _: "Record<string, unknown>",
"list": _resolve_ts_list,
"enum": lambda base: base.__name__,
}
# =============================================================================
# Protobuf Type Resolvers
# =============================================================================
def _resolve_proto_list(base: Any) -> str:
"""Resolve Protobuf repeated type."""
args = get_args(base)
if args:
inner = args[0]
if inner is str:
return "repeated string"
elif inner is int:
return "repeated int32"
elif inner is float:
return "repeated float"
elif inner is bool:
return "repeated bool"
return "repeated string"
PROTO_RESOLVERS: dict[Any, Callable[[Any], str]] = {
str: lambda _: "string",
int: lambda _: "int32",
float: lambda _: "float",
bool: lambda _: "bool",
"list": _resolve_proto_list,
}
# =============================================================================
# Prisma Type Mappings
# =============================================================================
PRISMA_TYPES: dict[Any, str] = {
str: "String",
int: "Int",
float: "Float",
bool: "Boolean",
"UUID": "String @default(uuid())",
"datetime": "DateTime",
"dict": "Json",
"list": "Json",
"bigint": "BigInt",
}
PRISMA_SPECIAL: dict[str, str] = {
"id": "String @id @default(uuid())",
"created_at": "DateTime @default(now())",
"updated_at": "DateTime @updatedAt",
}

View File

@@ -0,0 +1,7 @@
"""
Writer - File writing utilities for modelgen.
"""
from .file import write_file, write_multiple
__all__ = ["write_file", "write_multiple"]

View File

@@ -0,0 +1,30 @@
"""
File Writer
Utilities for writing generated files to disk.
"""
from pathlib import Path
from typing import Dict
def write_file(path: Path, content: str) -> None:
"""Write content to file, creating directories as needed."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
def write_multiple(directory: Path, files: Dict[str, str]) -> None:
"""Write multiple files to a directory.
Args:
directory: Target directory
files: Dict mapping filename to content
"""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
for filename, content in files.items():
file_path = directory / filename
file_path.write_text(content)

View File

@@ -92,18 +92,19 @@
function renderSystemSection(systemKey, title) {
const veins = config.veins || [];
// System icon - ALWAYS shown as non-clickable separator
// System link with icon and label (same as soleprint)
let html = `
<div class="spr-sidebar-icon" title="${title}">
<a href="${SPR_BASE}/${systemKey}" class="spr-sidebar-item" title="${title}">
${icons[systemKey]}
</div>
<span class="label">${title}</span>
<span class="tooltip">${title}</span>
</a>
`;
// Nested elements below icon - auth-gated
// Nested elements below - auth-gated
if (user) {
// Logged in
// Logged in - show vein links under artery
if (systemKey === "artery") {
// Show vein links under artery
for (const vein of veins) {
html += `
<a href="${SPR_BASE}/artery/${vein}" class="spr-sidebar-item spr-vein-item" title="${vein}">
@@ -112,14 +113,6 @@
</a>
`;
}
} else {
// Atlas/Station: clickable link to main page
html += `
<a href="${SPR_BASE}/${systemKey}" class="spr-sidebar-item spr-system-link" title="${title}">
<span class="label">${title}</span>
<span class="tooltip">${title}</span>
</a>
`;
}
} else if (config.auth_enabled) {
// Not logged in: show "login to access" below each system icon
@@ -159,7 +152,7 @@
<div class="spr-sidebar-divider"></div>
<a href="${SPR_BASE}/" class="spr-sidebar-item active" title="Soleprint">
<a href="/spr/" class="spr-sidebar-item active" title="Soleprint">
${icons.soleprint}
<span class="label">Soleprint</span>
<span class="tooltip">Soleprint</span>
@@ -204,9 +197,9 @@
</div>
`;
} else {
// Include current page as redirect target after login
const redirectUrl = encodeURIComponent(window.location.href);
const loginUrl = `${config.auth.login_url}?redirect=${redirectUrl}`;
// Include current path as redirect target after login (relative, not full URL)
const redirectPath = window.location.pathname + window.location.search;
const loginUrl = `${config.auth.login_url}?redirect=${encodeURIComponent(redirectPath)}`;
return `
<div class="spr-sidebar-user">
<a href="${loginUrl}" class="spr-sidebar-item" title="Login with Google">