#!/usr/bin/env python3
"""FastAPI front-end for the Octopus widget scaffolder."""
import json
import os
from pathlib import Path
from typing import Iterable
import uvicorn
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import (
HTMLResponse,
PlainTextResponse,
Response,
StreamingResponse,
)
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.responses import FileResponse
from scaffolder.generator import scaffold_widget
app = FastAPI(title="Octopus Widget Generator")
BASE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = BASE_DIR / "flask_templates"
OUTPUT_DIR = BASE_DIR / "output"
OUTPUT_DIR.mkdir(exist_ok=True)
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
app.mount(
"/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static"
)
[docs]
def to_pascal(name: str) -> str:
return name[0].upper() + name[1:] if name else name
[docs]
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
asset_version = int(
max(
(BASE_DIR / "static" / "js" / "script.js").stat().st_mtime,
(BASE_DIR / "static" / "css" / "style.css").stat().st_mtime,
)
)
# Compatibility across Starlette versions:
# - new: TemplateResponse(request, name, context)
# - old: TemplateResponse(name, context)
try:
return templates.TemplateResponse(
request,
"index.html",
{"request": request, "asset_version": asset_version},
)
except TypeError:
return templates.TemplateResponse(
"index.html",
{"request": request, "asset_version": asset_version},
)
def _prepare_stream(
pascal: str,
operations: list,
var_defaults: dict,
var_types: dict,
app_id: str,
feature_flags: dict | None,
) -> Iterable[str]:
for msg in scaffold_widget(
pascal,
"widget",
operations=operations,
var_defaults=var_defaults,
var_types=var_types,
app_id=app_id,
feature_flags=feature_flags,
):
if msg.startswith("ZIP_READY:"):
zip_path = msg.split("ZIP_READY:", 1)[1].strip()
yield f"ZIP_READY:/download/{Path(zip_path).name}\n"
else:
yield msg + "\n"
[docs]
@app.post("/generate")
async def generate(
pascal: str = Form(""),
app_id: str = Form(""),
ops: str = Form(""),
features: str = Form(""),
) -> Response:
pascal = to_pascal(pascal.strip())
app_id = app_id.strip()
try:
operations = json.loads(ops.strip()) if ops.strip() else []
except Exception as exc: # pragma: no cover - defensive logging only
return PlainTextResponse(f"ERROR: Invalid ops JSON: {exc}\n")
try:
feature_flags = (
json.loads(features.strip()) if features.strip() else {}
)
except Exception as exc:
return PlainTextResponse(f"ERROR: Invalid features JSON: {exc}\n")
var_defaults: dict = {}
var_types: dict = {}
for op in operations:
for key, spec in (op.get("vars") or {}).items():
var_defaults[key] = spec.get("default")
var_types[key] = spec.get("type")
stream = _prepare_stream(
pascal, operations, var_defaults, var_types, app_id, feature_flags
)
return StreamingResponse(stream, media_type="text/plain")
[docs]
@app.get("/download/{filename:path}")
async def download(filename: str) -> FileResponse:
candidate = (OUTPUT_DIR / filename).resolve()
try:
candidate.relative_to(OUTPUT_DIR.resolve())
except ValueError as exc:
raise HTTPException(status_code=404, detail="File not found") from exc
if not candidate.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(candidate, filename=candidate.name)
if __name__ == "__main__":
debug_mode = os.getenv("FASTAPI_DEBUG", "0").lower() in {
"1",
"true",
"yes",
}
reload_kwargs = {}
if debug_mode:
reload_kwargs = {"reload": True, "reload_dirs": [str(BASE_DIR)]}
uvicorn.run("app:app", host="0.0.0.0", port=5002, **reload_kwargs)