(Feat): Initial Commit.
This commit is contained in:
23
src/fastapi_route_loader/__init__.py
Normal file
23
src/fastapi_route_loader/__init__.py
Normal 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",
|
||||
]
|
||||
285
src/fastapi_route_loader/container.py
Normal file
285
src/fastapi_route_loader/container.py
Normal 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)
|
||||
122
src/fastapi_route_loader/events.py
Normal file
122
src/fastapi_route_loader/events.py
Normal 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()
|
||||
99
src/fastapi_route_loader/loader.py
Normal file
99
src/fastapi_route_loader/loader.py
Normal 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)
|
||||
0
src/fastapi_route_loader/py.typed
Normal file
0
src/fastapi_route_loader/py.typed
Normal file
Reference in New Issue
Block a user