(Feat): Initial Commit.

This commit is contained in:
2026-01-05 22:11:58 +00:00
commit b9224dd031
21 changed files with 3259 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
"""FastAPI Route Loader - Automatic router loading and management."""
from fastapi_route_loader.container import RouterContainer
from fastapi_route_loader.events import (
RouterEvent,
RouterEventType,
RouterLoadedEvent,
RouterUnloadedEvent,
RouterUpdatedEvent,
)
from fastapi_route_loader.loader import RouterLoader
__version__ = "0.1.0"
__all__ = [
"RouterContainer",
"RouterLoader",
"RouterEvent",
"RouterEventType",
"RouterLoadedEvent",
"RouterUnloadedEvent",
"RouterUpdatedEvent",
]

View File

@@ -0,0 +1,285 @@
"""Router container for managing APIRouter instances with filtering capabilities."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Callable, overload
from fastapi_route_loader.events import (
EventDispatcher,
RouterLoadedEvent,
RouterUnloadedEvent,
RouterUpdatedEvent,
)
from fastapi_route_loader.loader import RouterLoader
if TYPE_CHECKING:
from fastapi import APIRouter, FastAPI
from fastapi_route_loader.events import EventHandler, RouterEventType
class RouterContainer:
"""Container for managing APIRouter instances with filtering and events."""
def __init__(self) -> None:
self._routers: dict[str, APIRouter] = {}
self._excluded: set[str] = set()
self._included: set[str] | None = None
self._dispatcher = EventDispatcher()
def add_router(
self, name: str, router: APIRouter, metadata: dict[str, Any] | None = None
) -> None:
"""Add a router to the container.
Args:
name: Unique name for the router
router: APIRouter instance
metadata: Optional metadata for the event
Raises:
ValueError: If router name already exists
"""
if name in self._routers:
raise ValueError(f"Router '{name}' already exists")
self._routers[name] = router
event = RouterLoadedEvent(name, router, metadata)
self._dispatcher.dispatch(event)
def remove_router(
self, name: str, metadata: dict[str, Any] | None = None
) -> APIRouter:
"""Remove a router from the container.
Args:
name: Name of the router to remove
metadata: Optional metadata for the event
Returns:
The removed APIRouter instance
Raises:
KeyError: If router does not exist
"""
if name not in self._routers:
raise KeyError(f"Router '{name}' not found")
router = self._routers.pop(name)
event = RouterUnloadedEvent(name, router, metadata)
self._dispatcher.dispatch(event)
return router
def update_router(
self, name: str, router: APIRouter, metadata: dict[str, Any] | None = None
) -> None:
"""Update an existing router.
Args:
name: Name of the router to update
router: New APIRouter instance
metadata: Optional metadata for the event
Raises:
KeyError: If router does not exist
"""
if name not in self._routers:
raise KeyError(f"Router '{name}' not found")
old_router = self._routers[name]
self._routers[name] = router
event = RouterUpdatedEvent(name, router, old_router, metadata)
self._dispatcher.dispatch(event)
def get_router(self, name: str) -> APIRouter:
"""Get a router by name.
Args:
name: Name of the router
Returns:
The APIRouter instance
Raises:
KeyError: If router does not exist
"""
if name not in self._routers:
raise KeyError(f"Router '{name}' not found")
return self._routers[name]
def has_router(self, name: str) -> bool:
"""Check if a router exists.
Args:
name: Name of the router
Returns:
True if router exists, False otherwise
"""
return name in self._routers
def exclude(self, *names: str) -> None:
"""Exclude routers from being active.
Args:
names: Names of routers to exclude
"""
self._excluded.update(names)
def include(self, *names: str) -> None:
"""Set routers to be included (whitelist mode).
Args:
names: Names of routers to include
"""
if self._included is None:
self._included = set()
self._included.update(names)
def clear_filters(self) -> None:
"""Clear all include/exclude filters."""
self._excluded.clear()
self._included = None
def is_active(self, name: str) -> bool:
"""Check if a router is active (not filtered out).
Args:
name: Name of the router
Returns:
True if router is active, False otherwise
"""
if self._included is not None:
return name in self._included
return name not in self._excluded
def get_active_routers(self) -> dict[str, APIRouter]:
"""Get all active routers (after applying filters).
Returns:
Dictionary of active routers
"""
return {
name: router
for name, router in self._routers.items()
if self.is_active(name)
}
def get_all_routers(self) -> dict[str, APIRouter]:
"""Get all routers regardless of filters.
Returns:
Dictionary of all routers
"""
return self._routers.copy()
def load_from_module(self, module_path: str) -> None:
"""Load routers from a module.
Args:
module_path: Dotted module path
"""
routers = RouterLoader.load_from_module(module_path)
for name, router in routers.items():
self.add_router(name, router)
def load_from_directory(
self, directory: str, package: str | None = None
) -> None:
"""Load routers from a directory.
Args:
directory: Path to directory
package: Optional package name
"""
routers = RouterLoader.load_from_directory(directory, package)
for name, router in routers.items():
self.add_router(name, router)
def register_to_app(self, app: FastAPI, prefix: str = "") -> None:
"""Register all active routers to a FastAPI application.
Args:
app: FastAPI application instance
prefix: Optional prefix for all routes
"""
for router in self.get_active_routers().values():
app.include_router(router, prefix=prefix)
@overload
def on(
self, event_type: RouterEventType | None
) -> Callable[[EventHandler], EventHandler]: ...
@overload
def on(
self, event_type: RouterEventType | None, handler: EventHandler
) -> EventHandler: ...
def on(
self, event_type: RouterEventType | None, handler: EventHandler | None = None
) -> EventHandler | Callable[[EventHandler], EventHandler]:
"""Subscribe to router events.
Can be used as a method call or as a decorator.
Args:
event_type: Type of event to subscribe to (None for all events)
handler: Event handler function (optional when used as decorator)
Returns:
The handler function (for decorator usage)
Example:
As a method call:
>>> container.on(RouterEventType.LOADED, my_handler)
As a decorator:
>>> @container.on(RouterEventType.LOADED)
... def my_handler(event):
... pass
As a decorator for all events:
>>> @container.on(None)
... def my_handler(event):
... pass
"""
if handler is None:
# Decorator usage
def decorator(func: EventHandler) -> EventHandler:
self._dispatcher.subscribe(event_type, func)
return func
return decorator
else:
# Direct call usage
self._dispatcher.subscribe(event_type, handler)
return handler
def off(
self, event_type: RouterEventType | None, handler: EventHandler
) -> None:
"""Unsubscribe from router events.
Args:
event_type: Type of event to unsubscribe from (None for all events)
handler: Event handler function
"""
self._dispatcher.unsubscribe(event_type, handler)
def clear_handlers(self) -> None:
"""Clear all event handlers."""
self._dispatcher.clear()
def __len__(self) -> int:
"""Return the number of routers in the container."""
return len(self._routers)
def __contains__(self, name: str) -> bool:
"""Check if a router exists in the container."""
return name in self._routers
def __iter__(self) -> Any:
"""Iterate over router names."""
return iter(self._routers)

View File

@@ -0,0 +1,122 @@
"""Event system for router lifecycle management."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from fastapi import APIRouter
class RouterEventType(str, Enum):
"""Types of router events."""
LOADED = "loaded"
UNLOADED = "unloaded"
UPDATED = "updated"
@dataclass(frozen=True)
class RouterEvent:
"""Base class for router events."""
event_type: RouterEventType
router_name: str
router: APIRouter
metadata: dict[str, Any]
@dataclass(frozen=True)
class RouterLoadedEvent(RouterEvent):
"""Event dispatched when a router is loaded."""
def __init__(
self, router_name: str, router: APIRouter, metadata: dict[str, Any] | None = None
) -> None:
object.__setattr__(self, "event_type", RouterEventType.LOADED)
object.__setattr__(self, "router_name", router_name)
object.__setattr__(self, "router", router)
object.__setattr__(self, "metadata", metadata or {})
@dataclass(frozen=True)
class RouterUnloadedEvent(RouterEvent):
"""Event dispatched when a router is unloaded."""
def __init__(
self, router_name: str, router: APIRouter, metadata: dict[str, Any] | None = None
) -> None:
object.__setattr__(self, "event_type", RouterEventType.UNLOADED)
object.__setattr__(self, "router_name", router_name)
object.__setattr__(self, "router", router)
object.__setattr__(self, "metadata", metadata or {})
@dataclass(frozen=True)
class RouterUpdatedEvent(RouterEvent):
"""Event dispatched when a router is updated."""
old_router: APIRouter
def __init__(
self,
router_name: str,
router: APIRouter,
old_router: APIRouter,
metadata: dict[str, Any] | None = None,
) -> None:
object.__setattr__(self, "event_type", RouterEventType.UPDATED)
object.__setattr__(self, "router_name", router_name)
object.__setattr__(self, "router", router)
object.__setattr__(self, "old_router", old_router)
object.__setattr__(self, "metadata", metadata or {})
EventHandler = Callable[[RouterEvent], None]
class EventDispatcher:
"""Manages event handlers and dispatches events."""
def __init__(self) -> None:
self._handlers: dict[RouterEventType, list[EventHandler]] = {
RouterEventType.LOADED: [],
RouterEventType.UNLOADED: [],
RouterEventType.UPDATED: [],
}
self._global_handlers: list[EventHandler] = []
def subscribe(
self, event_type: RouterEventType | None, handler: EventHandler
) -> None:
"""Subscribe a handler to specific event type or all events."""
if event_type is None:
self._global_handlers.append(handler)
else:
self._handlers[event_type].append(handler)
def unsubscribe(
self, event_type: RouterEventType | None, handler: EventHandler
) -> None:
"""Unsubscribe a handler from specific event type or all events."""
if event_type is None:
if handler in self._global_handlers:
self._global_handlers.remove(handler)
else:
if handler in self._handlers[event_type]:
self._handlers[event_type].remove(handler)
def dispatch(self, event: RouterEvent) -> None:
"""Dispatch an event to all subscribed handlers."""
for handler in self._handlers[event.event_type]:
handler(event)
for handler in self._global_handlers:
handler(event)
def clear(self) -> None:
"""Clear all event handlers."""
for handlers in self._handlers.values():
handlers.clear()
self._global_handlers.clear()

View File

@@ -0,0 +1,99 @@
"""Router loader for automatic discovery and loading of APIRouters."""
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from fastapi import APIRouter
class RouterLoader:
"""Loads APIRouter instances from Python modules."""
@staticmethod
def load_from_module(module_path: str) -> dict[str, APIRouter]:
"""Load all APIRouter instances from a module.
Args:
module_path: Dotted module path (e.g., 'myapp.routers.users')
Returns:
Dictionary mapping router names to APIRouter instances
Raises:
ImportError: If module cannot be imported
"""
try:
module = importlib.import_module(module_path)
except ImportError as e:
raise ImportError(f"Failed to import module '{module_path}': {e}") from e
routers: dict[str, APIRouter] = {}
for name, obj in inspect.getmembers(module):
if RouterLoader._is_router(obj):
routers[name] = obj
return routers
@staticmethod
def load_from_directory(
directory: Path | str, package: str | None = None
) -> dict[str, APIRouter]:
"""Load all APIRouter instances from Python files in a directory.
Args:
directory: Path to directory containing router modules
package: Optional package name for relative imports
Returns:
Dictionary mapping router names to APIRouter instances
"""
directory_path = Path(directory)
if not directory_path.exists():
raise FileNotFoundError(f"Directory not found: {directory}")
if not directory_path.is_dir():
raise NotADirectoryError(f"Not a directory: {directory}")
routers: dict[str, APIRouter] = {}
for py_file in directory_path.rglob("*.py"):
if py_file.name.startswith("_"):
continue
module_path = RouterLoader._get_module_path(py_file, directory_path, package)
try:
module_routers = RouterLoader.load_from_module(module_path)
for name, router in module_routers.items():
full_name = f"{py_file.stem}.{name}"
routers[full_name] = router
except ImportError:
continue
return routers
@staticmethod
def _is_router(obj: Any) -> bool:
"""Check if an object is an APIRouter instance."""
try:
from fastapi import APIRouter
return isinstance(obj, APIRouter)
except ImportError:
return False
@staticmethod
def _get_module_path(
file_path: Path, base_path: Path, package: str | None
) -> str:
"""Convert file path to module path."""
relative = file_path.relative_to(base_path)
parts = list(relative.parts[:-1]) + [relative.stem]
if package:
return f"{package}.{'.'.join(parts)}"
return ".".join(parts)

View File