Files
lifetrack/apps/api/app/core/module_loader.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

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 ())