diff --git a/soleprint/run.py b/soleprint/run.py index 7c84d5d..68cd2d1 100644 --- a/soleprint/run.py +++ b/soleprint/run.py @@ -519,6 +519,18 @@ try: except ImportError as e: print(f"Warning: Could not load tester router: {e}") +try: + from station.tools.graphgen.api import router as graphgen_router + app.include_router(graphgen_router, prefix="/station") +except ImportError as e: + print(f"Warning: Could not load graphgen router: {e}") + +try: + from station.tools.datagen.api import router as datagen_router + app.include_router(datagen_router, prefix="/station") +except ImportError as e: + print(f"Warning: Could not load datagen router: {e}") + @app.get("/station/{path:path}") def station_route(path: str): diff --git a/soleprint/station/tools/datagen/__init__.py b/soleprint/station/tools/datagen/__init__.py index 9b63caa..aef571b 100644 --- a/soleprint/station/tools/datagen/__init__.py +++ b/soleprint/station/tools/datagen/__init__.py @@ -1 +1,5 @@ -"""Datagen - Test data generator for Amar domain models.""" +"""Datagen — test data generator. Room generators extend BaseDataGenerator.""" + +from .base import BaseDataGenerator + +__all__ = ["BaseDataGenerator"] diff --git a/soleprint/station/tools/datagen/api.py b/soleprint/station/tools/datagen/api.py new file mode 100644 index 0000000..ef501c8 --- /dev/null +++ b/soleprint/station/tools/datagen/api.py @@ -0,0 +1,180 @@ +"""FastAPI router for datagen — test data generator.""" + +import importlib.util +import logging +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse +from pydantic import BaseModel + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/tools/datagen", tags=["datagen"]) + +# soleprint/ root +SPR_ROOT = Path(__file__).parents[3] + +# Loaded generators: name → instance +_generators: dict[str, Any] = {} + + +# ───────────────────────────────────────────────────── +# Generator loading +# ───────────────────────────────────────────────────── + +def _load_generators() -> dict[str, Any]: + """ + Scan station/tools/datagen/ for generator classes and instantiate them. + + A generator file should define a class whose name ends in "Generator". + """ + datagen_dir = SPR_ROOT / "station" / "tools" / "datagen" + result = {} + + for py_file in sorted(datagen_dir.glob("*.py")): + if py_file.name.startswith("_") or py_file.stem in ("base", "api"): + continue + try: + spec = importlib.util.spec_from_file_location(py_file.stem, py_file) + if not spec or not spec.loader: + continue + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + # Find generator class (ends with Generator, not the base) + for name, obj in vars(mod).items(): + if ( + isinstance(obj, type) + and name.endswith("Generator") + and name != "BaseDataGenerator" + ): + result[py_file.stem] = obj() + log.info(f"datagen: loaded {name} from {py_file.name}") + break + except Exception as e: + log.warning(f"datagen: could not load {py_file.name}: {e}") + + return result + + +def _get_generators() -> dict[str, Any]: + """Return cached generators, loading on first call.""" + if not _generators: + _generators.update(_load_generators()) + return _generators + + +def _pick_generator(name: str | None) -> tuple[str, Any]: + """ + Pick a generator by name or use the only available one. + + Returns (name, instance). Raises HTTPException if not found. + """ + gens = _get_generators() + if not gens: + raise HTTPException(status_code=503, detail="No generators loaded") + + if name: + if name not in gens: + raise HTTPException( + status_code=404, + detail=f"Generator '{name}' not found. Available: {list(gens)}" + ) + return name, gens[name] + + # Default: first (usually only) generator + key = next(iter(gens)) + return key, gens[key] + + +# ───────────────────────────────────────────────────── +# Pydantic models +# ───────────────────────────────────────────────────── + +class GenerateRequest(BaseModel): + model: str + count: int = 1 + generator: str | None = None + kwargs: dict = {} + + +# ───────────────────────────────────────────────────── +# Routes +# ───────────────────────────────────────────────────── + +@router.get("/", response_class=HTMLResponse) +def index(): + html = Path(__file__).parent / "templates" / "index.html" + return HTMLResponse(html.read_text() if html.exists() else "

datagen

") + + +@router.get("/health") +def health(): + return {"status": "ok", "tool": "datagen"} + + +@router.get("/api/generators") +def list_generators(): + """List loaded generator files.""" + gens = _get_generators() + return { + "generators": [ + { + "name": k, + "models": v.available_models() if hasattr(v, "available_models") else [], + } + for k, v in gens.items() + ] + } + + +@router.get("/api/models") +def list_models(generator: str | None = None): + """List available model generators.""" + _, gen = _pick_generator(generator) + if hasattr(gen, "available_models"): + models = gen.available_models() + else: + models = [ + n for n in dir(gen) + if not n.startswith("_") and callable(getattr(gen, n)) + ] + return {"models": models} + + +@router.post("/api/generate") +def generate(req: GenerateRequest): + """Generate test data for a model.""" + gen_name, gen = _pick_generator(req.generator) + try: + if hasattr(gen, "generate"): + items = gen.generate(req.model, req.count, **req.kwargs) + else: + method = getattr(gen, req.model.lower(), None) + if not method: + raise ValueError(f"No method '{req.model.lower()}' on generator") + items = [method(**req.kwargs) for _ in range(req.count)] + except (ValueError, TypeError) as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + log.exception(f"datagen: generate error") + raise HTTPException(status_code=500, detail=str(e)) + + return { + "generator": gen_name, + "model": req.model, + "count": len(items), + "items": items, + } + + +@router.get("/api/schema") +def get_schema(generator: str | None = None): + """Return graphgen-compatible schema from the generator (if it provides one).""" + _, gen = _pick_generator(generator) + if hasattr(gen, "schema"): + s = gen.schema() + if s: + return s + return {"models": {}, "note": "This generator does not expose a schema."} diff --git a/soleprint/station/tools/datagen/base.py b/soleprint/station/tools/datagen/base.py new file mode 100644 index 0000000..2d092b5 --- /dev/null +++ b/soleprint/station/tools/datagen/base.py @@ -0,0 +1,80 @@ +""" +BaseDataGenerator — base class for room-specific data generators. + +Room generators extend this class and define methods named after their models: + + class MyGenerator(BaseDataGenerator): + def user(self, **kwargs): + return {"id": uuid4(), "name": fake.name(), **kwargs} + + def product(self, category=None, **kwargs): + return {"id": uuid4(), "name": fake.word(), "category": category, **kwargs} + +The base class provides: +- generate(model, count, **kwargs) — call the right method N times +- available_models() — list of method names (= model names) +- schema() — override to return graphgen-compatible schema +""" + + +class BaseDataGenerator: + """Base class for room-specific data generators.""" + + # Attribute names that are NOT model generators + _RESERVED = frozenset({"generate", "available_models", "schema"}) + + def generate(self, model: str, count: int = 1, **kwargs) -> list[dict]: + """ + Generate `count` instances of the given model. + + Args: + model: Model name (case-insensitive, matches method name). + count: Number of items to generate. + **kwargs: Passed through to the model method on every call. + + Returns: + List of generated dicts. + + Raises: + ValueError if no generator exists for the model. + """ + method = getattr(self, model.lower(), None) + if method is None or not callable(method): + available = self.available_models() + raise ValueError( + f"No generator for '{model}'. " + f"Available: {available if available else '(none)'}" + ) + return [method(**kwargs) for _ in range(count)] + + def available_models(self) -> list[str]: + """Return sorted list of model names this generator supports.""" + return sorted( + name for name in dir(self) + if not name.startswith("_") + and name not in self._RESERVED + and callable(getattr(self, name)) + ) + + def schema(self) -> dict | None: + """ + Optional: return a graphgen-compatible schema dict. + + Override to integrate with graphgen's /api/schema — datagen will + surface this via /tools/datagen/api/schema. + + Expected format: + { + "models": { + "User": { + "doc": "...", + "fields": { + "id": {"type": "UUID", "pk": true}, + "email": {"type": "str"}, + "org_id": {"type": "FK:Organization"} + } + } + } + } + """ + return None diff --git a/soleprint/station/tools/datagen/templates/index.html b/soleprint/station/tools/datagen/templates/index.html new file mode 100644 index 0000000..0640694 --- /dev/null +++ b/soleprint/station/tools/datagen/templates/index.html @@ -0,0 +1,497 @@ + + + + + + datagen — Test Data Generator + + + +
+

datagen

+ + loading… +
+ +
+
+ +
+
Model
+ +
+ + +
+
Count
+
+ +
+
+
1
+
5
+
10
+
25
+
+
+ + +
+
Override fields (optional)
+
+ +
+ + +
+ + +
+
+ + +
+
+
+
+ Select a model and click Generate +
Results appear here as formatted JSON
+
+ +
+
+
+ + + + diff --git a/soleprint/station/tools/graphgen/__init__.py b/soleprint/station/tools/graphgen/__init__.py new file mode 100644 index 0000000..dcfd0e7 --- /dev/null +++ b/soleprint/station/tools/graphgen/__init__.py @@ -0,0 +1 @@ +"""Graphgen — interactive DB schema visualization.""" diff --git a/soleprint/station/tools/graphgen/api.py b/soleprint/station/tools/graphgen/api.py new file mode 100644 index 0000000..e6f730c --- /dev/null +++ b/soleprint/station/tools/graphgen/api.py @@ -0,0 +1,50 @@ +"""FastAPI router for graphgen — schema graph explorer.""" + +import logging +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from .schema import load_graph_schema + +log = logging.getLogger(__name__) +router = APIRouter(prefix="/tools/graphgen", tags=["graphgen"]) + +# soleprint/ root (this file: soleprint/station/tools/graphgen/api.py) +SPR_ROOT = Path(__file__).parents[3] + + +def _schema_paths() -> list[Path]: + """Return ordered list of directories to search for schema.""" + paths = [] + + # cfg//station/tools/graphgen/ (room-specific, highest priority) + cfg_dir = SPR_ROOT / "cfg" + if cfg_dir.exists(): + for room in sorted(cfg_dir.iterdir()): + if room.is_dir() and not room.name.startswith(("_", ".")): + paths.append(room / "station" / "tools" / "graphgen") + + # station/tools/modelgen/ (shared modelgen schema/) + paths.append(SPR_ROOT / "station" / "tools" / "modelgen") + + return paths + + +@router.get("/", response_class=HTMLResponse) +def index(): + """Serve the graph viewer UI.""" + html = Path(__file__).parent / "templates" / "index.html" + return HTMLResponse(html.read_text() if html.exists() else "

graphgen

") + + +@router.get("/health") +def health(): + return {"status": "ok", "tool": "graphgen"} + + +@router.get("/api/schema") +def get_schema(): + """Return graph-ready schema: {models, relationships, source}.""" + return load_graph_schema(_schema_paths()) diff --git a/soleprint/station/tools/graphgen/schema.py b/soleprint/station/tools/graphgen/schema.py new file mode 100644 index 0000000..f1cd0bb --- /dev/null +++ b/soleprint/station/tools/graphgen/schema.py @@ -0,0 +1,174 @@ +""" +Schema reader for graphgen. + +Loads schema from multiple sources (priority order): +1. schema.json in a cfg/*/station/tools/graphgen/ directory +2. modelgen schema/ folder (Python dataclasses) + +Output format: +{ + "models": [{"id", "name", "doc", "fields": [{"name", "type", "pk", "fk", "m2m", "nullable"}]}], + "relationships": [{"from_model", "from_field", "to_model", "type"}], + "source": "path or description" +} +""" + +import json +import logging +from pathlib import Path +from typing import Any + +log = logging.getLogger(__name__) + + +def load_graph_schema(search_paths: list[Path]) -> dict: + """Search for schema in given paths and return graph-ready data.""" + for base in search_paths: + if not base.exists(): + continue + + # schema.json (JSON format) + json_path = base / "schema.json" + if json_path.exists(): + log.info(f"graphgen: loading schema from {json_path}") + return _load_json_schema(json_path) + + # modelgen schema/ folder + py_schema = base / "schema" + if (py_schema / "__init__.py").exists(): + log.info(f"graphgen: loading Python schema from {py_schema}") + return _load_py_schema(py_schema) + + return {"models": [], "relationships": [], "source": "empty"} + + +def _load_json_schema(path: Path) -> dict: + """Load from schema.json — simple declarative format.""" + try: + data = json.loads(path.read_text()) + except Exception as e: + log.warning(f"graphgen: could not parse {path}: {e}") + return {"models": [], "relationships": [], "source": str(path), "error": str(e)} + + models = [] + relationships = [] + + for model_name, model_data in data.get("models", {}).items(): + fields = [] + for field_name, field_data in model_data.get("fields", {}).items(): + field_type = field_data.get("type", "str") + fk_target = None + m2m = False + + if field_type.startswith("FK:"): + fk_target = field_type[3:] + field_type = "FK" + relationships.append({ + "from_model": model_name, + "from_field": field_name, + "to_model": fk_target, + "type": "FK", + }) + elif field_type.startswith("M2M:"): + fk_target = field_type[4:] + field_type = "M2M" + m2m = True + relationships.append({ + "from_model": model_name, + "from_field": field_name, + "to_model": fk_target, + "type": "M2M", + }) + + fields.append({ + "name": field_name, + "type": field_type, + "pk": field_data.get("pk", False), + "fk": fk_target, + "m2m": m2m, + "nullable": field_data.get("nullable", False), + }) + + models.append({ + "id": model_name, + "name": model_name, + "doc": model_data.get("doc", ""), + "fields": fields, + }) + + return { + "models": models, + "relationships": relationships, + "source": str(path), + } + + +def _load_py_schema(schema_dir: Path) -> dict: + """Load from modelgen Python dataclasses schema/ folder.""" + try: + from ..modelgen.loader.schema import SchemaLoader + + loader = SchemaLoader(schema_dir).load() + return _convert_modelgen(loader, str(schema_dir)) + except Exception as e: + log.warning(f"graphgen: Python schema load failed: {e}") + return { + "models": [], + "relationships": [], + "source": str(schema_dir), + "error": str(e), + } + + +def _convert_modelgen(loader: Any, source: str) -> dict: + """Convert modelgen SchemaLoader output to graph data.""" + all_names = {m.name for m in loader.models} + models = [] + relationships = [] + + for model_def in loader.models: + fields = [] + for field in model_def.fields: + type_str = _type_str(field.type_hint) + fk_target = None + + # FK: type name that matches another model + if type_str in all_names: + fk_target = type_str + relationships.append({ + "from_model": model_def.name, + "from_field": field.name, + "to_model": fk_target, + "type": "FK", + }) + # Django extractor produces "FK" / "M2M" strings + elif type_str == "FK": + fk_target = None # target unknown from extractor + + fields.append({ + "name": field.name, + "type": type_str, + "pk": field.name == "id", + "fk": fk_target, + "m2m": type_str == "M2M", + "nullable": field.optional, + }) + + models.append({ + "id": model_def.name, + "name": model_def.name, + "doc": model_def.docstring or "", + "fields": fields, + }) + + return {"models": models, "relationships": relationships, "source": source} + + +def _type_str(t: Any) -> str: + if t is None: + return "Any" + if isinstance(t, str): + return t + if hasattr(t, "__name__"): + return t.__name__ + return str(t) diff --git a/soleprint/station/tools/graphgen/templates/index.html b/soleprint/station/tools/graphgen/templates/index.html new file mode 100644 index 0000000..c3edb0e --- /dev/null +++ b/soleprint/station/tools/graphgen/templates/index.html @@ -0,0 +1,729 @@ + + + + + + graphgen — Schema Explorer + + + +
+

graphgen

+ + loading… +
+ + +
+
+ +
+
+
+ + + + + + + + + + +
+
+

No schema found

+

Add a schema.json to
cfg/<room>/station/tools/graphgen/

+
+
100%
+
+ + +
+ + + +