Files
shop/backend/core/loader.py
Marek Lenczewski e3e88cc58e wahnsinn vibe
2026-04-16 19:42:06 +02:00

100 lines
3.1 KiB
Python

"""App-Loader: discovers apps/<name>/manifest.yaml, resolves deps, imports and registers."""
from __future__ import annotations
import importlib
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from fastapi import APIRouter, FastAPI
APPS_DIR = Path(__file__).resolve().parent.parent / "apps"
@dataclass
class AppManifest:
name: str
version: str = "0.1.0"
depends_on: list[str] = field(default_factory=list)
conflicts_with: list[str] = field(default_factory=list)
required: bool = False
provides: list[str] = field(default_factory=list)
def discover_manifests() -> list[AppManifest]:
manifests: list[AppManifest] = []
if not APPS_DIR.exists():
return manifests
for entry in sorted(APPS_DIR.iterdir()):
mf_path = entry / "manifest.yaml"
if not mf_path.exists():
continue
data = yaml.safe_load(mf_path.read_text()) or {}
manifests.append(
AppManifest(
name=data["name"],
version=data.get("version", "0.1.0"),
depends_on=list(data.get("depends_on", [])),
conflicts_with=list(data.get("conflicts_with", [])),
required=bool(data.get("required", False)),
provides=list(data.get("provides", [])),
)
)
return manifests
def _topo_sort(manifests: list[AppManifest]) -> list[AppManifest]:
by_name = {m.name: m for m in manifests}
visited: set[str] = set()
temp: set[str] = set()
order: list[AppManifest] = []
def visit(name: str) -> None:
if name in visited:
return
if name in temp:
raise RuntimeError(f"Circular app dependency at {name}")
m = by_name.get(name)
if not m:
return # dependency on core or non-app (e.g. 'core')
temp.add(name)
for d in m.depends_on:
if d == "core":
continue
if d not in by_name:
raise RuntimeError(f"App {name} depends on missing app: {d}")
visit(d)
temp.discard(name)
visited.add(name)
order.append(m)
for m in manifests:
visit(m.name)
return order
def _check_conflicts(manifests: list[AppManifest]) -> None:
names = {m.name for m in manifests}
for m in manifests:
for c in m.conflicts_with:
if c in names:
raise RuntimeError(f"App conflict: {m.name} conflicts with {c}")
def load_apps(app: FastAPI) -> list[AppManifest]:
manifests = discover_manifests()
_check_conflicts(manifests)
ordered = _topo_sort(manifests)
loaded: list[AppManifest] = []
for m in ordered:
module_name = f"apps.{m.name}"
mod = importlib.import_module(module_name)
# Optional lifecycle hook
if hasattr(mod, "on_load") and callable(mod.on_load):
mod.on_load()
if hasattr(mod, "router") and isinstance(mod.router, APIRouter):
app.include_router(mod.router, prefix=f"/api/{m.name}", tags=[m.name])
loaded.append(m)
print(f"[app-loader] loaded {m.name} v{m.version}")
return loaded