Compare commits
9 Commits
38c2cfe50f
...
47b4b87851
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b4b87851 | |||
| b4081cff3e | |||
|
|
35796c0c3b | ||
|
|
0351e5c7a6 | ||
|
|
3df1465bf5 | ||
|
|
dcc5191ba3 | ||
|
|
220d3dc5a6 | ||
|
|
fa7bbe3953 | ||
|
|
ed1c8f6c96 |
30
build.py
30
build.py
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"showcase_url": "https://sample.spr.mcrn.ar",
|
||||
"framework": {
|
||||
"name": "soleprint",
|
||||
"slug": "soleprint",
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
"system": "artery"
|
||||
},
|
||||
{
|
||||
"name": "vnc",
|
||||
"slug": "vnc",
|
||||
"title": "VNC",
|
||||
"name": "vpn",
|
||||
"slug": "vpn",
|
||||
"title": "VPN",
|
||||
"status": "planned",
|
||||
"system": "artery"
|
||||
},
|
||||
|
||||
@@ -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 |
191
docs/architecture/sidebar-injection.html
Normal file
191
docs/architecture/sidebar-injection.html
Normal 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 </head>?</span><span class="lang-es">Por que inyectar en </head>?</span></h3>
|
||||
<p class="lang-en">Next.js and streaming SSR may not include </body> in initial response. </head> is always present.</p>
|
||||
<p class="lang-es">Next.js y SSR streaming pueden no incluir </body> en la respuesta inicial. </head> 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 '</head>'
|
||||
'<link rel="stylesheet" href="/spr/sidebar.css">
|
||||
<script src="/spr/sidebar.js" defer></script></head>';
|
||||
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
314
docs/artery/index.html
Normal 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
209
docs/atlas/index.html
Normal 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>
|
||||
267
docs/index.html
267
docs/index.html
@@ -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
54
docs/lang-toggle.js
Normal 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
336
docs/station/index.html
Normal 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>
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
0
soleprint/artery/shunts/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/example/README.md
Normal file → Executable file
0
soleprint/artery/shunts/example/README.md
Normal file → Executable file
0
soleprint/artery/shunts/example/depot/responses.json
Normal file → Executable file
0
soleprint/artery/shunts/example/depot/responses.json
Normal file → Executable file
0
soleprint/artery/shunts/example/main.py
Normal file → Executable file
0
soleprint/artery/shunts/example/main.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/.env.example
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/.env.example
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/README.md
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/README.md
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/routes.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/api/routes.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/__init__.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/config.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/core/config.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/main.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/main.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/requirements.txt
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/requirements.txt
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/run.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/run.py
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/templates/index.html
Normal file → Executable file
0
soleprint/artery/shunts/mercadopago/templates/index.html
Normal file → Executable 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:
|
||||
|
||||
@@ -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 = "/"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
40
soleprint/station/tools/modelgen/generator/__init__.py
Normal file
40
soleprint/station/tools/modelgen/generator/__init__.py
Normal 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",
|
||||
]
|
||||
23
soleprint/station/tools/modelgen/generator/base.py
Normal file
23
soleprint/station/tools/modelgen/generator/base.py
Normal 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
|
||||
268
soleprint/station/tools/modelgen/generator/django.py
Normal file
268
soleprint/station/tools/modelgen/generator/django.py
Normal 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 ""
|
||||
)
|
||||
173
soleprint/station/tools/modelgen/generator/prisma.py
Normal file
173
soleprint/station/tools/modelgen/generator/prisma.py
Normal 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
|
||||
168
soleprint/station/tools/modelgen/generator/protobuf.py
Normal file
168
soleprint/station/tools/modelgen/generator/protobuf.py
Normal 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
|
||||
427
soleprint/station/tools/modelgen/generator/pydantic.py
Normal file
427
soleprint/station/tools/modelgen/generator/pydantic.py
Normal 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)
|
||||
'''
|
||||
144
soleprint/station/tools/modelgen/generator/typescript.py
Normal file
144
soleprint/station/tools/modelgen/generator/typescript.py
Normal 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
|
||||
72
soleprint/station/tools/modelgen/helpers.py
Normal file
72
soleprint/station/tools/modelgen/helpers.py
Normal 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]
|
||||
37
soleprint/station/tools/modelgen/loader/__init__.py
Normal file
37
soleprint/station/tools/modelgen/loader/__init__.py
Normal 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",
|
||||
]
|
||||
@@ -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}")
|
||||
20
soleprint/station/tools/modelgen/loader/extract/__init__.py
Normal file
20
soleprint/station/tools/modelgen/loader/extract/__init__.py
Normal 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"]
|
||||
38
soleprint/station/tools/modelgen/loader/extract/base.py
Normal file
38
soleprint/station/tools/modelgen/loader/extract/base.py
Normal 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
|
||||
237
soleprint/station/tools/modelgen/loader/extract/django.py
Normal file
237
soleprint/station/tools/modelgen/loader/extract/django.py
Normal 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)
|
||||
169
soleprint/station/tools/modelgen/loader/schema.py
Normal file
169
soleprint/station/tools/modelgen/loader/schema.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
0
soleprint/station/tools/modelgen/schema/.gitkeep
Normal file
0
soleprint/station/tools/modelgen/schema/.gitkeep
Normal file
139
soleprint/station/tools/modelgen/types.py
Normal file
139
soleprint/station/tools/modelgen/types.py
Normal 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",
|
||||
}
|
||||
7
soleprint/station/tools/modelgen/writer/__init__.py
Normal file
7
soleprint/station/tools/modelgen/writer/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Writer - File writing utilities for modelgen.
|
||||
"""
|
||||
|
||||
from .file import write_file, write_multiple
|
||||
|
||||
__all__ = ["write_file", "write_multiple"]
|
||||
30
soleprint/station/tools/modelgen/writer/file.py
Normal file
30
soleprint/station/tools/modelgen/writer/file.py
Normal 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)
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user