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