feat: AR-ElecArrangement initial commit — Python FastAPI + uvicorn (LAN desktop app, packaged as .exe via PyInstaller)
This commit is contained in:
+196
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
AR-ElecArrangement — entrypoint del servidor.
|
||||
|
||||
Lanza FastAPI/uvicorn en ``0.0.0.0:5505`` (accesible desde la LAN para que
|
||||
los tablets se conecten) y sirve el frontend compilado de ``frontend/dist/``.
|
||||
|
||||
Cuando se ejecuta como ``python -m backend.main`` o desde el ``.exe`` empacado
|
||||
por PyInstaller, después de iniciar el server espera 1 s y abre el browser
|
||||
predeterminado apuntando a ``http://localhost:5505``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from arelec import __version__
|
||||
from arelec.core.project import Project
|
||||
|
||||
# ── Paths ────────────────────────────────────────────────────────────────────
|
||||
# En desarrollo: __file__ = backend/main.py → BACKEND_DIR = backend/
|
||||
# Empacado: __file__ está dentro de _MEIPASS → BACKEND_DIR = sys._MEIPASS/backend
|
||||
if getattr(sys, "frozen", False):
|
||||
BASE_DIR = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
else:
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
FRONTEND_DIST = BASE_DIR / "frontend" / "dist"
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
PORT = 5505
|
||||
|
||||
|
||||
# ── FastAPI app ──────────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Hook de arranque/apagado del server."""
|
||||
print(f"[arelec] Servidor iniciado en http://localhost:{PORT} (v{__version__})")
|
||||
print(f"[arelec] Tablets en LAN: http://<ip-de-este-pc>:{PORT}")
|
||||
yield
|
||||
print("[arelec] Servidor detenido.")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="AR-ElecArrangement",
|
||||
version=__version__,
|
||||
description="Diseño eléctrico de buques — backend",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Acceso LAN sin restricciones (la app está pensada para LAN privada del usuario,
|
||||
# no para Internet pública). Si en el futuro se ofrece cloud, restringir aquí.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
|
||||
|
||||
# ── API ──────────────────────────────────────────────────────────────────────
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
"""Heartbeat para clientes que verifican que el server está vivo."""
|
||||
return {"status": "ok", "version": __version__}
|
||||
|
||||
|
||||
# ── Project I/O (Sprint 0 — sólo metadata, expandirá en Sprint 1+) ──────────
|
||||
@app.post("/api/project/new")
|
||||
def project_new() -> dict[str, Any]:
|
||||
"""Crear un proyecto vacío y devolver su JSON serializado."""
|
||||
return Project().to_json()
|
||||
|
||||
|
||||
@app.post("/api/project/open")
|
||||
async def project_open(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
"""
|
||||
Abrir un archivo ``.area`` subido por el cliente. Lo guarda en un tempfile,
|
||||
lo carga vía ``Project.load``, devuelve el JSON serializado.
|
||||
"""
|
||||
if not (file.filename or "").lower().endswith(".area"):
|
||||
raise HTTPException(400, "El archivo debe tener extensión .area")
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Archivo vacío")
|
||||
with tempfile.NamedTemporaryFile(suffix=".area", delete=False) as tmp:
|
||||
tmp.write(data)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
proj = Project.load(tmp_path)
|
||||
return proj.to_json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
# No exponemos str(e) al cliente para evitar filtrar rutas internas
|
||||
# o detalles de implementación. El tipo de error es suficiente.
|
||||
raise HTTPException(400, "No es un .area válido o el archivo está dañado.") from e
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
_WINDOWS_RESERVED_RE = re.compile(
|
||||
r"^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
"""Sanitiza el nombre del proyecto para uso seguro como nombre de archivo.
|
||||
|
||||
- Elimina caracteres no permitidos en Windows/POSIX (``/``, ``\\``, ``:``,
|
||||
``*``, ``?``, ``"``, ``<``, ``>``, ``|``, bytes nulos).
|
||||
- Colapsa espacios/guiones bajos múltiples consecutivos.
|
||||
- Trunca a 200 caracteres para evitar rutas demasiado largas.
|
||||
- Sustituye cadena vacía o nombres reservados de Windows por ``proyecto``.
|
||||
"""
|
||||
safe = re.sub(r'[\\/:*?"<>|\x00]', "_", name)
|
||||
safe = re.sub(r"[_\s]{2,}", "_", safe).strip("_").strip()
|
||||
safe = safe[:200]
|
||||
if not safe or _WINDOWS_RESERVED_RE.match(safe):
|
||||
safe = "proyecto"
|
||||
return safe
|
||||
|
||||
|
||||
@app.post("/api/project/save")
|
||||
async def project_save(payload: dict[str, Any]) -> FileResponse:
|
||||
"""
|
||||
Recibe un proyecto en JSON, lo serializa a ``.area`` y lo devuelve para
|
||||
descarga directa por el browser.
|
||||
"""
|
||||
proj = Project.from_json(payload)
|
||||
out_dir = Path(tempfile.mkdtemp(prefix="arelec_save_"))
|
||||
out_path = out_dir / (_safe_filename(proj.metadata.name) + ".area")
|
||||
proj.save(out_path)
|
||||
return FileResponse(
|
||||
path=out_path,
|
||||
media_type="application/zip",
|
||||
filename=out_path.name,
|
||||
)
|
||||
|
||||
|
||||
# ── Frontend (montaje al final para que /api/* tenga precedencia) ────────────
|
||||
if FRONTEND_DIST.exists():
|
||||
app.mount("/", StaticFiles(directory=str(FRONTEND_DIST), html=True), name="frontend")
|
||||
else:
|
||||
@app.get("/")
|
||||
def _no_frontend_msg() -> dict[str, str]:
|
||||
return {
|
||||
"warning": "frontend/dist no encontrado",
|
||||
"hint": "Compilar frontend: cd frontend && npm install && npm run build",
|
||||
}
|
||||
|
||||
|
||||
# ── Auto-launch del browser cuando corre como .exe ───────────────────────────
|
||||
def _open_browser_when_ready() -> None:
|
||||
"""Espera 1 s y abre el browser default. Llamado solo desde .exe / __main__."""
|
||||
time.sleep(1.0)
|
||||
url = f"http://localhost:{PORT}"
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[arelec] No se pudo abrir el browser automáticamente: {e}")
|
||||
print(f"[arelec] Abre manualmente: {url}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entrypoint cuando se corre como ``python -m backend.main`` o ``.exe``."""
|
||||
# Solo abrir browser cuando no estamos en modo --reload (dev)
|
||||
if os.environ.get("ARELEC_NO_BROWSER") != "1":
|
||||
threading.Thread(target=_open_browser_when_ready, daemon=True).start()
|
||||
|
||||
uvicorn.run(
|
||||
"backend.main:app" if not getattr(sys, "frozen", False) else app,
|
||||
host=HOST,
|
||||
port=PORT,
|
||||
log_level="info",
|
||||
reload=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user