(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

0
tests/__init__.py Normal file
View File

351
tests/test_container.py Normal file
View File

@@ -0,0 +1,351 @@
"""Tests for router container."""
import sys
import pytest
from fastapi import APIRouter, FastAPI
from fastapi_route_loader.container import RouterContainer
from fastapi_route_loader.events import (
RouterEventType,
RouterLoadedEvent,
RouterUnloadedEvent,
RouterUpdatedEvent,
)
class TestRouterContainer:
"""Test router container functionality."""
def test_add_router(self):
container = RouterContainer()
router = APIRouter()
container.add_router("test_router", router)
assert container.has_router("test_router")
assert container.get_router("test_router") is router
assert len(container) == 1
def test_add_router_duplicate_raises_error(self):
container = RouterContainer()
router = APIRouter()
container.add_router("test_router", router)
with pytest.raises(ValueError, match="Router 'test_router' already exists"):
container.add_router("test_router", router)
def test_add_router_dispatches_event(self):
container = RouterContainer()
router = APIRouter()
events_received = []
def handler(event):
events_received.append(event)
container.on(RouterEventType.LOADED, handler)
container.add_router("test_router", router, {"key": "value"})
assert len(events_received) == 1
event = events_received[0]
assert isinstance(event, RouterLoadedEvent)
assert event.router_name == "test_router"
assert event.router is router
assert event.metadata == {"key": "value"}
def test_remove_router(self):
container = RouterContainer()
router = APIRouter()
container.add_router("test_router", router)
removed_router = container.remove_router("test_router")
assert removed_router is router
assert not container.has_router("test_router")
assert len(container) == 0
def test_remove_router_not_found_raises_error(self):
container = RouterContainer()
with pytest.raises(KeyError, match="Router 'nonexistent' not found"):
container.remove_router("nonexistent")
def test_remove_router_dispatches_event(self):
container = RouterContainer()
router = APIRouter()
events_received = []
def handler(event):
events_received.append(event)
container.add_router("test_router", router)
container.on(RouterEventType.UNLOADED, handler)
container.remove_router("test_router", {"reason": "cleanup"})
assert len(events_received) == 1
event = events_received[0]
assert isinstance(event, RouterUnloadedEvent)
assert event.router_name == "test_router"
assert event.router is router
assert event.metadata == {"reason": "cleanup"}
def test_update_router(self):
container = RouterContainer()
old_router = APIRouter()
new_router = APIRouter()
container.add_router("test_router", old_router)
container.update_router("test_router", new_router)
assert container.get_router("test_router") is new_router
def test_update_router_not_found_raises_error(self):
container = RouterContainer()
router = APIRouter()
with pytest.raises(KeyError, match="Router 'nonexistent' not found"):
container.update_router("nonexistent", router)
def test_update_router_dispatches_event(self):
container = RouterContainer()
old_router = APIRouter()
new_router = APIRouter()
events_received = []
def handler(event):
events_received.append(event)
container.add_router("test_router", old_router)
container.on(RouterEventType.UPDATED, handler)
container.update_router("test_router", new_router, {"version": "2.0"})
assert len(events_received) == 1
event = events_received[0]
assert isinstance(event, RouterUpdatedEvent)
assert event.router_name == "test_router"
assert event.router is new_router
assert event.old_router is old_router
assert event.metadata == {"version": "2.0"}
def test_get_router_not_found_raises_error(self):
container = RouterContainer()
with pytest.raises(KeyError, match="Router 'nonexistent' not found"):
container.get_router("nonexistent")
def test_exclude_routers(self):
container = RouterContainer()
router1 = APIRouter()
router2 = APIRouter()
router3 = APIRouter()
container.add_router("router1", router1)
container.add_router("router2", router2)
container.add_router("router3", router3)
container.exclude("router2")
assert container.is_active("router1")
assert not container.is_active("router2")
assert container.is_active("router3")
active_routers = container.get_active_routers()
assert "router1" in active_routers
assert "router2" not in active_routers
assert "router3" in active_routers
def test_include_routers(self):
container = RouterContainer()
router1 = APIRouter()
router2 = APIRouter()
router3 = APIRouter()
container.add_router("router1", router1)
container.add_router("router2", router2)
container.add_router("router3", router3)
container.include("router1", "router3")
assert container.is_active("router1")
assert not container.is_active("router2")
assert container.is_active("router3")
active_routers = container.get_active_routers()
assert "router1" in active_routers
assert "router2" not in active_routers
assert "router3" in active_routers
def test_clear_filters(self):
container = RouterContainer()
router1 = APIRouter()
router2 = APIRouter()
container.add_router("router1", router1)
container.add_router("router2", router2)
container.exclude("router1")
assert not container.is_active("router1")
container.clear_filters()
assert container.is_active("router1")
assert container.is_active("router2")
def test_get_all_routers(self):
container = RouterContainer()
router1 = APIRouter()
router2 = APIRouter()
container.add_router("router1", router1)
container.add_router("router2", router2)
container.exclude("router1")
all_routers = container.get_all_routers()
assert len(all_routers) == 2
assert "router1" in all_routers
assert "router2" in all_routers
def test_load_from_module(self, tmp_path):
module_content = '''
from fastapi import APIRouter
test_router = APIRouter()
'''
module_file = tmp_path / "test_module.py"
module_file.write_text(module_content)
sys.path.insert(0, str(tmp_path))
try:
container = RouterContainer()
container.load_from_module("test_module")
assert container.has_router("test_router")
finally:
sys.path.remove(str(tmp_path))
if "test_module" in sys.modules:
del sys.modules["test_module"]
def test_load_from_directory(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(routers_dir / "users.py").write_text(
"from fastapi import APIRouter\nuser_router = APIRouter()"
)
sys.path.insert(0, str(tmp_path))
try:
container = RouterContainer()
container.load_from_directory(str(routers_dir), "routers")
assert container.has_router("users.user_router")
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_register_to_app(self):
container = RouterContainer()
router1 = APIRouter()
router2 = APIRouter()
container.add_router("router1", router1)
container.add_router("router2", router2)
container.exclude("router2")
app = FastAPI()
container.register_to_app(app)
assert len(app.routes) > 0
def test_register_to_app_with_prefix(self):
container = RouterContainer()
router = APIRouter()
router.get("/")(lambda: {"message": "test"})
container.add_router("router", router)
app = FastAPI()
container.register_to_app(app, prefix="/api")
route_paths = [route.path for route in app.routes]
assert any("/api" in path for path in route_paths)
def test_event_subscription(self):
container = RouterContainer()
router = APIRouter()
events_received = []
def handler(event):
events_received.append(event)
container.on(None, handler)
container.add_router("test_router", router)
container.remove_router("test_router")
assert len(events_received) == 2
def test_event_unsubscription(self):
container = RouterContainer()
router = APIRouter()
events_received = []
def handler(event):
events_received.append(event)
container.on(RouterEventType.LOADED, handler)
container.off(RouterEventType.LOADED, handler)
container.add_router("test_router", router)
assert len(events_received) == 0
def test_clear_handlers(self):
container = RouterContainer()
router = APIRouter()
events_received = []
def handler(event):
events_received.append(event)
container.on(RouterEventType.LOADED, handler)
container.clear_handlers()
container.add_router("test_router", router)
assert len(events_received) == 0
def test_container_len(self):
container = RouterContainer()
assert len(container) == 0
container.add_router("router1", APIRouter())
assert len(container) == 1
container.add_router("router2", APIRouter())
assert len(container) == 2
container.remove_router("router1")
assert len(container) == 1
def test_container_contains(self):
container = RouterContainer()
router = APIRouter()
container.add_router("test_router", router)
assert "test_router" in container
assert "nonexistent" not in container
def test_container_iter(self):
container = RouterContainer()
container.add_router("router1", APIRouter())
container.add_router("router2", APIRouter())
container.add_router("router3", APIRouter())
router_names = list(container)
assert "router1" in router_names
assert "router2" in router_names
assert "router3" in router_names
assert len(router_names) == 3

190
tests/test_events.py Normal file
View File

@@ -0,0 +1,190 @@
"""Tests for event system."""
import pytest
from fastapi import APIRouter
from fastapi_route_loader.events import (
EventDispatcher,
RouterEvent,
RouterEventType,
RouterLoadedEvent,
RouterUnloadedEvent,
RouterUpdatedEvent,
)
class TestRouterEvents:
"""Test router event classes."""
def test_router_loaded_event(self):
router = APIRouter()
event = RouterLoadedEvent("test_router", router, {"key": "value"})
assert event.event_type == RouterEventType.LOADED
assert event.router_name == "test_router"
assert event.router is router
assert event.metadata == {"key": "value"}
def test_router_loaded_event_no_metadata(self):
router = APIRouter()
event = RouterLoadedEvent("test_router", router)
assert event.event_type == RouterEventType.LOADED
assert event.metadata == {}
def test_router_unloaded_event(self):
router = APIRouter()
event = RouterUnloadedEvent("test_router", router, {"reason": "cleanup"})
assert event.event_type == RouterEventType.UNLOADED
assert event.router_name == "test_router"
assert event.router is router
assert event.metadata == {"reason": "cleanup"}
def test_router_updated_event(self):
old_router = APIRouter()
new_router = APIRouter()
event = RouterUpdatedEvent(
"test_router", new_router, old_router, {"version": "2.0"}
)
assert event.event_type == RouterEventType.UPDATED
assert event.router_name == "test_router"
assert event.router is new_router
assert event.old_router is old_router
assert event.metadata == {"version": "2.0"}
def test_event_immutability(self):
router = APIRouter()
event = RouterLoadedEvent("test_router", router)
with pytest.raises(AttributeError):
event.router_name = "new_name"
class TestEventDispatcher:
"""Test event dispatcher."""
def test_subscribe_and_dispatch(self):
dispatcher = EventDispatcher()
router = APIRouter()
events_received = []
def handler(event: RouterEvent) -> None:
events_received.append(event)
dispatcher.subscribe(RouterEventType.LOADED, handler)
event = RouterLoadedEvent("test_router", router)
dispatcher.dispatch(event)
assert len(events_received) == 1
assert events_received[0] is event
def test_subscribe_to_all_events(self):
dispatcher = EventDispatcher()
router = APIRouter()
events_received = []
def handler(event: RouterEvent) -> None:
events_received.append(event)
dispatcher.subscribe(None, handler)
loaded_event = RouterLoadedEvent("test_router", router)
dispatcher.dispatch(loaded_event)
unloaded_event = RouterUnloadedEvent("test_router", router)
dispatcher.dispatch(unloaded_event)
assert len(events_received) == 2
assert events_received[0] is loaded_event
assert events_received[1] is unloaded_event
def test_multiple_handlers(self):
dispatcher = EventDispatcher()
router = APIRouter()
handler1_calls = []
handler2_calls = []
def handler1(event: RouterEvent) -> None:
handler1_calls.append(event)
def handler2(event: RouterEvent) -> None:
handler2_calls.append(event)
dispatcher.subscribe(RouterEventType.LOADED, handler1)
dispatcher.subscribe(RouterEventType.LOADED, handler2)
event = RouterLoadedEvent("test_router", router)
dispatcher.dispatch(event)
assert len(handler1_calls) == 1
assert len(handler2_calls) == 1
def test_unsubscribe(self):
dispatcher = EventDispatcher()
router = APIRouter()
events_received = []
def handler(event: RouterEvent) -> None:
events_received.append(event)
dispatcher.subscribe(RouterEventType.LOADED, handler)
dispatcher.unsubscribe(RouterEventType.LOADED, handler)
event = RouterLoadedEvent("test_router", router)
dispatcher.dispatch(event)
assert len(events_received) == 0
def test_unsubscribe_global_handler(self):
dispatcher = EventDispatcher()
router = APIRouter()
events_received = []
def handler(event: RouterEvent) -> None:
events_received.append(event)
dispatcher.subscribe(None, handler)
dispatcher.unsubscribe(None, handler)
event = RouterLoadedEvent("test_router", router)
dispatcher.dispatch(event)
assert len(events_received) == 0
def test_clear_handlers(self):
dispatcher = EventDispatcher()
router = APIRouter()
events_received = []
def handler(event: RouterEvent) -> None:
events_received.append(event)
dispatcher.subscribe(RouterEventType.LOADED, handler)
dispatcher.subscribe(None, handler)
dispatcher.clear()
event = RouterLoadedEvent("test_router", router)
dispatcher.dispatch(event)
assert len(events_received) == 0
def test_handler_exception_does_not_stop_other_handlers(self):
dispatcher = EventDispatcher()
router = APIRouter()
handler2_calls = []
def handler1(event: RouterEvent) -> None:
raise ValueError("Handler error")
def handler2(event: RouterEvent) -> None:
handler2_calls.append(event)
dispatcher.subscribe(RouterEventType.LOADED, handler1)
dispatcher.subscribe(RouterEventType.LOADED, handler2)
event = RouterLoadedEvent("test_router", router)
with pytest.raises(ValueError, match="Handler error"):
dispatcher.dispatch(event)

261
tests/test_integration.py Normal file
View File

@@ -0,0 +1,261 @@
"""Integration tests for the complete library."""
import sys
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from fastapi_route_loader import RouterContainer, RouterEventType
class TestIntegration:
"""Test complete integration scenarios."""
def test_full_workflow(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(routers_dir / "users.py").write_text('''
from fastapi import APIRouter
users_router = APIRouter(prefix="/users", tags=["users"])
@users_router.get("/")
def list_users():
return {"users": []}
@users_router.get("/{user_id}")
def get_user(user_id: int):
return {"user_id": user_id}
''')
(routers_dir / "posts.py").write_text('''
from fastapi import APIRouter
posts_router = APIRouter(prefix="/posts", tags=["posts"])
@posts_router.get("/")
def list_posts():
return {"posts": []}
''')
sys.path.insert(0, str(tmp_path))
try:
container = RouterContainer()
container.load_from_directory(str(routers_dir), "routers")
assert container.has_router("users.users_router")
assert container.has_router("posts.posts_router")
app = FastAPI()
container.register_to_app(app)
client = TestClient(app)
response = client.get("/users/")
assert response.status_code == 200
assert response.json() == {"users": []}
response = client.get("/users/123")
assert response.status_code == 200
assert response.json() == {"user_id": 123}
response = client.get("/posts/")
assert response.status_code == 200
assert response.json() == {"posts": []}
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_filtering_workflow(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(routers_dir / "public.py").write_text('''
from fastapi import APIRouter
public_router = APIRouter(prefix="/public")
@public_router.get("/")
def public_endpoint():
return {"message": "public"}
''')
(routers_dir / "admin.py").write_text('''
from fastapi import APIRouter
admin_router = APIRouter(prefix="/admin")
@admin_router.get("/")
def admin_endpoint():
return {"message": "admin"}
''')
sys.path.insert(0, str(tmp_path))
try:
container = RouterContainer()
container.load_from_directory(str(routers_dir), "routers")
container.exclude("admin.admin_router")
app = FastAPI()
container.register_to_app(app)
client = TestClient(app)
response = client.get("/public/")
assert response.status_code == 200
response = client.get("/admin/")
assert response.status_code == 404
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_event_tracking_workflow(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(routers_dir / "api.py").write_text('''
from fastapi import APIRouter
api_router = APIRouter()
''')
sys.path.insert(0, str(tmp_path))
try:
container = RouterContainer()
events_log = []
def event_logger(event):
events_log.append({
"type": event.event_type.value,
"router": event.router_name,
})
container.on(None, event_logger)
container.load_from_directory(str(routers_dir), "routers")
assert len(events_log) == 1
assert events_log[0]["type"] == "loaded"
assert events_log[0]["router"] == "api.api_router"
new_router = APIRouter()
container.update_router("api.api_router", new_router)
assert len(events_log) == 2
assert events_log[1]["type"] == "updated"
container.remove_router("api.api_router")
assert len(events_log) == 3
assert events_log[2]["type"] == "unloaded"
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_manual_router_management(self):
container = RouterContainer()
users_router = APIRouter(prefix="/users")
@users_router.get("/")
def list_users():
return {"users": []}
posts_router = APIRouter(prefix="/posts")
@posts_router.get("/")
def list_posts():
return {"posts": []}
container.add_router("users", users_router)
container.add_router("posts", posts_router)
app = FastAPI()
container.register_to_app(app)
client = TestClient(app)
response = client.get("/users/")
assert response.status_code == 200
response = client.get("/posts/")
assert response.status_code == 200
def test_include_only_specific_routers(self):
container = RouterContainer()
router1 = APIRouter(prefix="/api1")
router2 = APIRouter(prefix="/api2")
router3 = APIRouter(prefix="/api3")
@router1.get("/")
def api1():
return {"api": 1}
@router2.get("/")
def api2():
return {"api": 2}
@router3.get("/")
def api3():
return {"api": 3}
container.add_router("api1", router1)
container.add_router("api2", router2)
container.add_router("api3", router3)
container.include("api1", "api3")
app = FastAPI()
container.register_to_app(app)
client = TestClient(app)
response = client.get("/api1/")
assert response.status_code == 200
response = client.get("/api2/")
assert response.status_code == 404
response = client.get("/api3/")
assert response.status_code == 200
def test_event_handlers_for_specific_types(self):
container = RouterContainer()
loaded_count = [0]
unloaded_count = [0]
updated_count = [0]
def on_loaded(event):
loaded_count[0] += 1
def on_unloaded(event):
unloaded_count[0] += 1
def on_updated(event):
updated_count[0] += 1
container.on(RouterEventType.LOADED, on_loaded)
container.on(RouterEventType.UNLOADED, on_unloaded)
container.on(RouterEventType.UPDATED, on_updated)
router1 = APIRouter()
router2 = APIRouter()
container.add_router("router1", router1)
assert loaded_count[0] == 1
container.update_router("router1", router2)
assert updated_count[0] == 1
container.remove_router("router1")
assert unloaded_count[0] == 1

160
tests/test_loader.py Normal file
View File

@@ -0,0 +1,160 @@
"""Tests for router loader."""
import sys
from pathlib import Path
import pytest
from fastapi import APIRouter
from fastapi_route_loader.loader import RouterLoader
class TestRouterLoader:
"""Test router loader functionality."""
def test_is_router_with_api_router(self):
router = APIRouter()
assert RouterLoader._is_router(router) is True
def test_is_router_with_non_router(self):
assert RouterLoader._is_router("not a router") is False
assert RouterLoader._is_router(123) is False
assert RouterLoader._is_router(None) is False
def test_get_module_path_without_package(self):
base_path = Path("/app/routers")
file_path = Path("/app/routers/users/api.py")
module_path = RouterLoader._get_module_path(file_path, base_path, None)
assert module_path == "users.api"
def test_get_module_path_with_package(self):
base_path = Path("/app/routers")
file_path = Path("/app/routers/users/api.py")
module_path = RouterLoader._get_module_path(file_path, base_path, "myapp")
assert module_path == "myapp.users.api"
def test_get_module_path_single_file(self):
base_path = Path("/app/routers")
file_path = Path("/app/routers/api.py")
module_path = RouterLoader._get_module_path(file_path, base_path, None)
assert module_path == "api"
def test_load_from_module_success(self, tmp_path):
module_content = '''
from fastapi import APIRouter
router = APIRouter()
other_router = APIRouter()
not_a_router = "test"
'''
module_file = tmp_path / "test_module.py"
module_file.write_text(module_content)
sys.path.insert(0, str(tmp_path))
try:
routers = RouterLoader.load_from_module("test_module")
assert "router" in routers
assert "other_router" in routers
assert "not_a_router" not in routers
assert isinstance(routers["router"], APIRouter)
finally:
sys.path.remove(str(tmp_path))
if "test_module" in sys.modules:
del sys.modules["test_module"]
def test_load_from_module_import_error(self):
with pytest.raises(ImportError, match="Failed to import module"):
RouterLoader.load_from_module("nonexistent_module_xyz")
def test_load_from_directory_success(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(routers_dir / "users.py").write_text(
"from fastapi import APIRouter\nuser_router = APIRouter()"
)
(routers_dir / "posts.py").write_text(
"from fastapi import APIRouter\npost_router = APIRouter()"
)
(routers_dir / "_private.py").write_text(
"from fastapi import APIRouter\nprivate_router = APIRouter()"
)
sys.path.insert(0, str(tmp_path))
try:
routers = RouterLoader.load_from_directory(routers_dir, "routers")
assert "users.user_router" in routers
assert "posts.post_router" in routers
assert "private.private_router" not in routers
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_load_from_directory_not_found(self):
with pytest.raises(FileNotFoundError, match="Directory not found"):
RouterLoader.load_from_directory("/nonexistent/path")
def test_load_from_directory_not_a_directory(self, tmp_path):
file_path = tmp_path / "not_a_dir.txt"
file_path.write_text("test")
with pytest.raises(NotADirectoryError, match="Not a directory"):
RouterLoader.load_from_directory(file_path)
def test_load_from_directory_nested_structure(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
api_dir = routers_dir / "api"
api_dir.mkdir()
v1_dir = api_dir / "v1"
v1_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(api_dir / "__init__.py").write_text("")
(v1_dir / "__init__.py").write_text("")
(v1_dir / "users.py").write_text(
"from fastapi import APIRouter\nusers_router = APIRouter()"
)
sys.path.insert(0, str(tmp_path))
try:
routers = RouterLoader.load_from_directory(routers_dir, "routers")
assert "users.users_router" in routers
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_load_from_directory_with_import_errors(self, tmp_path):
routers_dir = tmp_path / "routers"
routers_dir.mkdir()
(routers_dir / "__init__.py").write_text("")
(routers_dir / "good.py").write_text(
"from fastapi import APIRouter\ngood_router = APIRouter()"
)
(routers_dir / "bad.py").write_text(
"import nonexistent_module\nfrom fastapi import APIRouter\nbad_router = APIRouter()"
)
sys.path.insert(0, str(tmp_path))
try:
routers = RouterLoader.load_from_directory(routers_dir, "routers")
assert "good.good_router" in routers
assert "bad.bad_router" not in routers
finally:
sys.path.remove(str(tmp_path))
for mod in list(sys.modules.keys()):
if mod.startswith("routers"):
del sys.modules[mod]
def test_load_from_directory_empty(self, tmp_path):
routers_dir = tmp_path / "empty_routers"
routers_dir.mkdir()
routers = RouterLoader.load_from_directory(routers_dir)
assert routers == {}