Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
import importlib
|
|
import os
|
|
import pkgutil
|
|
from collections.abc import Iterable, Iterator
|
|
|
|
from fastapi import APIRouter, FastAPI
|
|
from starlette.routing import BaseRoute
|
|
|
|
from app import modules as modules_pkg
|
|
|
|
# Optional side-effect files imported for every module (models -> metadata,
|
|
# importers/ingest -> registries populated by decorators).
|
|
_SIDE_FILES = ("models", "importers", "ingest")
|
|
|
|
# Optional comma-separated whitelist of module folder names; unset/empty means
|
|
# "discover everything". Used by parallel test runs to isolate modules.
|
|
_MODULES_ENV_VAR = "LIFETRACK_MODULES"
|
|
|
|
|
|
def _whitelist() -> set[str] | None:
|
|
raw = os.environ.get(_MODULES_ENV_VAR, "").strip()
|
|
if not raw:
|
|
return None
|
|
return {part.strip() for part in raw.split(",") if part.strip()}
|
|
|
|
|
|
def iter_module_names() -> list[str]:
|
|
allowed = _whitelist()
|
|
names = sorted(
|
|
info.name
|
|
for info in pkgutil.iter_modules(modules_pkg.__path__)
|
|
if not info.name.startswith("_")
|
|
)
|
|
if allowed is not None:
|
|
names = [name for name in names if name in allowed]
|
|
return names
|
|
|
|
|
|
def import_side_modules() -> None:
|
|
for name in iter_module_names():
|
|
for side in _SIDE_FILES:
|
|
dotted = f"app.modules.{name}.{side}"
|
|
try:
|
|
importlib.import_module(dotted)
|
|
except ModuleNotFoundError as exc:
|
|
# Only swallow "file does not exist"; re-raise real import errors
|
|
# coming from inside the file (missing dependency, typo…).
|
|
if exc.name != dotted:
|
|
raise
|
|
|
|
|
|
def register_routers(app: FastAPI) -> None:
|
|
for name in iter_module_names():
|
|
mod = importlib.import_module(f"app.modules.{name}.router")
|
|
router = getattr(mod, "router", None)
|
|
if not isinstance(router, APIRouter):
|
|
# RuntimeError (not TypeError): broken module contract, per §3.4.
|
|
raise RuntimeError( # noqa: TRY004
|
|
f"Module '{name}' must expose an APIRouter named 'router' in router.py"
|
|
)
|
|
app.include_router(router, prefix="/api")
|
|
# Optional extra routers mounted under a different prefix (ex: the
|
|
# imports module also serves /api/ingest/{domain}, see §5.5).
|
|
extra_routers = getattr(mod, "extra_routers", None) or []
|
|
for extra in extra_routers:
|
|
if not isinstance(extra, APIRouter):
|
|
# RuntimeError (not TypeError): broken module contract, per §3.4.
|
|
raise RuntimeError( # noqa: TRY004
|
|
f"Module '{name}': every item of 'extra_routers' must be an APIRouter"
|
|
)
|
|
app.include_router(extra, prefix="/api")
|
|
|
|
|
|
def iter_mounted_routes(app: FastAPI) -> Iterator[tuple[str, frozenset[str]]]:
|
|
"""Yield `(full_path, http_methods)` for every route mounted on `app`.
|
|
|
|
`app.routes` cannot be read directly: since FastAPI 0.140 `include_router()`
|
|
appends an opaque `_IncludedRouter` wrapper instead of copying the
|
|
sub-router's routes into the parent, so module endpoints never appear as
|
|
top-level `APIRoute` objects. This walks the wrappers (recursively, since a
|
|
module may itself include sub-routers — `imports` does) and rebuilds the
|
|
effective paths. Falls back to plain iteration on older FastAPI versions.
|
|
"""
|
|
yield from _walk_routes(app.routes, "")
|
|
|
|
|
|
def _walk_routes(
|
|
routes: Iterable[BaseRoute], prefix: str
|
|
) -> Iterator[tuple[str, frozenset[str]]]:
|
|
for route in routes:
|
|
included = getattr(route, "original_router", None)
|
|
if included is not None:
|
|
# `include_context.prefix` already carries the parent router's own
|
|
# prefix, so it is not accumulated twice.
|
|
context = getattr(route, "include_context", None)
|
|
yield from _walk_routes(
|
|
included.routes, prefix + getattr(context, "prefix", "")
|
|
)
|
|
continue
|
|
path = getattr(route, "path", None)
|
|
if path is None:
|
|
continue
|
|
yield prefix + path, frozenset(getattr(route, "methods", None) or ())
|