Skip to content

Commit 2480d54

Browse files
author
deeleeramone
committed
fix route introspection for fastapi 0.137
1 parent 8b0ac2e commit 2480d54

18 files changed

Lines changed: 246 additions & 148 deletions

File tree

cli/uv.lock

Lines changed: 64 additions & 45 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openbb_platform/core/openbb_core/api/app_loader.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from openbb_core.api.exception_handlers import ExceptionHandlers
88
from openbb_core.app.model.abstract.error import OpenBBError
9+
from openbb_core.app.route_iter import iter_included_routers
910
from openbb_core.app.router import RouterLoader
1011
from openbb_core.provider.utils.errors import EmptyDataError, UnauthorizedError
1112

@@ -19,6 +20,7 @@ def add_routers(app: FastAPI, routers: list[APIRouter | None], prefix: str):
1920
for router in routers:
2021
if router:
2122
app.include_router(router=router, prefix=prefix)
23+
AppLoader.deduplicate_router_event_handlers(app)
2224

2325
@staticmethod
2426
def add_openapi_tags(app: FastAPI):
@@ -36,9 +38,20 @@ def add_openapi_tags(app: FastAPI):
3638
@staticmethod
3739
def add_exception_handlers(app: FastAPI):
3840
"""Add exception handlers."""
39-
app.exception_handlers[Exception] = ExceptionHandlers.exception
40-
app.exception_handlers[ValidationError] = ExceptionHandlers.validation
41-
app.exception_handlers[ResponseValidationError] = ExceptionHandlers.validation
42-
app.exception_handlers[OpenBBError] = ExceptionHandlers.openbb
43-
app.exception_handlers[EmptyDataError] = ExceptionHandlers.empty_data
44-
app.exception_handlers[UnauthorizedError] = ExceptionHandlers.unauthorized
41+
app.add_exception_handler(Exception, ExceptionHandlers.exception)
42+
app.add_exception_handler(ValidationError, ExceptionHandlers.validation) # ty: ignore[invalid-argument-type]
43+
app.add_exception_handler(ResponseValidationError, ExceptionHandlers.validation) # ty: ignore[invalid-argument-type]
44+
app.add_exception_handler(OpenBBError, ExceptionHandlers.openbb) # ty: ignore[invalid-argument-type]
45+
app.add_exception_handler(EmptyDataError, ExceptionHandlers.empty_data) # ty: ignore[invalid-argument-type]
46+
app.add_exception_handler(UnauthorizedError, ExceptionHandlers.unauthorized) # ty: ignore[invalid-argument-type]
47+
48+
@staticmethod
49+
def deduplicate_router_event_handlers(app: FastAPI) -> None:
50+
"""Clear startup/shutdown handlers on included routers after assembly."""
51+
for included in iter_included_routers(app.router):
52+
on_startup = getattr(included, "on_startup", None)
53+
on_shutdown = getattr(included, "on_shutdown", None)
54+
if isinstance(on_startup, list):
55+
on_startup.clear()
56+
if isinstance(on_shutdown, list):
57+
on_shutdown.clear()

openbb_platform/core/openbb_core/api/rest_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ async def lifespan(_: FastAPI):
6565
for s in system.api_settings.servers
6666
],
6767
lifespan=lifespan,
68+
strict_content_type=False,
6869
)
6970
app.add_middleware(
7071
CORSMiddleware,

openbb_platform/core/openbb_core/api/router/commands.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from openbb_core.app.model.command_context import CommandContext
2020
from openbb_core.app.model.obbject import OBBject
2121
from openbb_core.app.model.user_settings import UserSettings
22+
from openbb_core.app.route_iter import iter_api_routes
2223
from openbb_core.app.router import RouterLoader
2324
from openbb_core.app.service.auth_service import AuthService
2425
from openbb_core.app.service.system_service import SystemService
@@ -353,9 +354,16 @@ async def wrapper( # noqa: PLR0912
353354
def add_command_map(command_runner: CommandRunner, api_router: APIRouter) -> None:
354355
"""Add command map to the API router."""
355356
plugins_router = RouterLoader.from_extensions()
356-
357-
for route in plugins_router.api_router.routes:
358-
route.endpoint = build_api_wrapper(command_runner=command_runner, route=route) # type: ignore # noqa
357+
for effective in iter_api_routes(plugins_router.api_router):
358+
leaf = getattr(effective, "original_route", effective)
359+
if not isinstance(leaf, APIRoute):
360+
continue
361+
original_leaf_path = leaf.path
362+
leaf.path = effective.path
363+
try:
364+
leaf.endpoint = build_api_wrapper(command_runner=command_runner, route=leaf)
365+
finally:
366+
leaf.path = original_leaf_path
359367
api_router.include_router(router=plugins_router.api_router)
360368

361369

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Route iteration helpers."""
2+
3+
from collections.abc import Iterator
4+
from typing import Any
5+
6+
from fastapi.routing import APIRoute
7+
8+
_IncludedRouter: type | None = None
9+
try: # noqa: SIM105
10+
from fastapi.routing import (
11+
_IncludedRouter, # type: ignore[attr-defined,no-redef]
12+
)
13+
except ImportError: # pragma: no cover
14+
pass
15+
16+
17+
def iter_api_routes(router: Any) -> Iterator:
18+
"""Yield prefix-resolved route descriptors from a router or app."""
19+
if not hasattr(router, "routes"):
20+
return
21+
for route in router.routes:
22+
if _IncludedRouter is not None and isinstance(route, _IncludedRouter):
23+
yield from route.effective_route_contexts()
24+
elif isinstance(route, APIRoute):
25+
yield route
26+
27+
28+
def iter_included_routers(router: Any) -> Iterator:
29+
"""Yield every original ``APIRouter`` reachable through ``include_router``."""
30+
if not hasattr(router, "routes") or _IncludedRouter is None:
31+
return
32+
for route in router.routes:
33+
if isinstance(route, _IncludedRouter):
34+
yield route.original_router
35+
yield from iter_included_routers(route.original_router)

openbb_platform/core/openbb_core/app/router.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
ProviderInterface,
3030
StandardParams,
3131
)
32+
from openbb_core.app.route_iter import iter_api_routes
3233
from openbb_core.env import Env
3334

3435
P = ParamSpec("P")
@@ -430,7 +431,9 @@ def get_command_map(
430431
) -> dict[str, Callable]:
431432
"""Get command map."""
432433
api_router = router.api_router
433-
command_map = {route.path: route.endpoint for route in api_router.routes} # type: ignore
434+
command_map = {
435+
route.path: route.endpoint for route in iter_api_routes(api_router)
436+
}
434437
return command_map
435438

436439
@staticmethod
@@ -443,7 +446,7 @@ def get_provider_coverage(
443446
mapping = ProviderInterface().map
444447

445448
coverage_map: dict[Any, Any] = {}
446-
for route in api_router.routes:
449+
for route in iter_api_routes(api_router):
447450
openapi_extra = getattr(route, "openapi_extra", None)
448451
if openapi_extra:
449452
model = openapi_extra.get("model", None)
@@ -458,7 +461,7 @@ def get_provider_coverage(
458461
rp = (
459462
route.path
460463
if sep is None
461-
else route.path.replace("/", sep) # type: ignore
464+
else route.path.replace("/", sep)
462465
)
463466
coverage_map[provider].append(rp)
464467

@@ -474,7 +477,7 @@ def get_command_coverage(
474477
mapping = ProviderInterface().map
475478

476479
coverage_map: dict[Any, Any] = {}
477-
for route in api_router.routes:
480+
for route in iter_api_routes(api_router):
478481
openapi_extra = getattr(route, "openapi_extra")
479482
if openapi_extra:
480483
model = openapi_extra.get("model", None)
@@ -484,7 +487,7 @@ def get_command_coverage(
484487
providers.remove("openbb")
485488

486489
if hasattr(route, "path"):
487-
rp = route.path if sep is None else route.path.replace("/", sep) # type: ignore
490+
rp = route.path if sep is None else route.path.replace("/", sep)
488491
if route.path not in coverage_map:
489492
coverage_map[rp] = []
490493
coverage_map[rp] = providers
@@ -496,12 +499,12 @@ def get_commands_model(router: Router, sep: str | None = None) -> dict[str, str]
496499
api_router = router.api_router
497500

498501
coverage_map: dict[Any, Any] = {}
499-
for route in api_router.routes:
502+
for route in iter_api_routes(api_router):
500503
openapi_extra = getattr(route, "openapi_extra")
501504
if openapi_extra:
502505
model = openapi_extra.get("model", None)
503506
if model and hasattr(route, "path"):
504-
rp = route.path if sep is None else route.path.replace("/", sep) # type: ignore
507+
rp = route.path if sep is None else route.path.replace("/", sep)
505508
if route.path not in coverage_map:
506509
coverage_map[rp] = []
507510
coverage_map[rp] = model

openbb_platform/core/openbb_core/app/static/package_builder/path_handler.py

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
TypeVar,
77
)
88

9-
from fastapi.routing import APIRoute
109
from starlette.routing import BaseRoute
1110

11+
from openbb_core.app.route_iter import iter_api_routes
1212
from openbb_core.app.router import RouterLoader
1313

1414
if TYPE_CHECKING:
@@ -83,41 +83,16 @@ def get_router_dependencies(path: str) -> list:
8383
def build_route_map() -> dict[str, BaseRoute]:
8484
"""Build the route map."""
8585
router = RouterLoader.from_extensions()
86-
route_map = {
87-
route.path: route
88-
for route in router.api_router.routes
89-
if isinstance(route, APIRoute)
90-
and "." not in str(route.path)
91-
and getattr(route, "include_in_schema", True)
92-
}
93-
94-
# Also include routes directly registered on _api_router instances
95-
# We need to traverse the router tree to find all _api_router instances
96-
def collect_api_router_routes(router_obj, collected_routes):
97-
"""Recursively collect routes from _api_router instances."""
98-
if hasattr(router_obj, "_api_router"):
99-
for inner_route in router_obj._api_router.routes:
100-
if (
101-
isinstance(inner_route, APIRoute)
102-
and getattr(inner_route, "include_in_schema", True)
103-
and (inner_route.path not in collected_routes)
104-
):
105-
collected_routes[inner_route.path] = inner_route
106-
107-
# Check if this router has sub-routers
108-
if hasattr(router_obj, "api_router") and hasattr(
109-
router_obj.api_router, "routes"
110-
):
111-
for route in router_obj.api_router.routes:
112-
if not isinstance(route, APIRoute):
113-
continue
114-
endpoint = getattr(route, "endpoint", None)
115-
if endpoint and hasattr(endpoint, "__self__"):
116-
collect_api_router_routes(endpoint.__self__, collected_routes)
117-
118-
collect_api_router_routes(router, route_map)
119-
120-
return route_map # type: ignore
86+
route_map: dict[str, BaseRoute] = {}
87+
for route in iter_api_routes(router.api_router):
88+
path = getattr(route, "path", None)
89+
if path is None:
90+
continue
91+
if not getattr(route, "include_in_schema", True):
92+
continue
93+
if path not in route_map:
94+
route_map[path] = route # type: ignore[assignment]
95+
return route_map
12196

12297
@staticmethod
12398
def build_path_list(route_map: dict[str, BaseRoute]) -> list[str]:

openbb_platform/core/tests/api/test_rest_api.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
from openbb_core.api import rest_api
2121
from openbb_core.api.rest_api import app, system
22+
from openbb_core.app.route_iter import iter_api_routes
2223

2324

2425
def test_openapi_schema_metadata_matches_system_settings():
@@ -71,7 +72,7 @@ def test_router_inclusion_invariant_commands_imply_coverage():
7172
The invariant we test: if there exists any command route, coverage
7273
routes must exist. This catches accidental removal of the conditional.
7374
"""
74-
paths = {getattr(r, "path", None) for r in app.routes}
75+
paths = {getattr(r, "path", None) for r in iter_api_routes(app)}
7576
prefix = system.api_settings.prefix
7677

7778
coverage_paths = {

openbb_platform/core/tests/api/test_router/test_commands_helpers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,5 +432,10 @@ async def wrapped():
432432
api_router = APIRouter()
433433
add_command_map(MagicMock(spec=CommandRunner), api_router)
434434

435+
from openbb_core.app.route_iter import iter_api_routes
436+
435437
assert plugins_api_router.routes[0].endpoint is wrapped
436-
assert any(getattr(route, "path", None) == "/plugin" for route in api_router.routes)
438+
assert any(
439+
getattr(route, "path", None) == "/plugin"
440+
for route in iter_api_routes(api_router)
441+
)

openbb_platform/core/tests/app/static/package_builder/test_method_definition_helpers.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1457,8 +1457,13 @@ def endpoint(symbol: str = "AAPL"):
14571457
monkeypatch.setattr(
14581458
MethodDefinition, "is_deprecated_function", staticmethod(lambda p: False)
14591459
)
1460-
monkeypatch.setattr(
1461-
md.inspect, "getsource", lambda _f: (_ for _ in ()).throw(TypeError("x"))
1462-
)
1460+
real_getsource = md.inspect.getsource
1461+
1462+
def _patched_getsource(obj):
1463+
if obj is endpoint:
1464+
raise TypeError("x")
1465+
return real_getsource(obj)
1466+
1467+
monkeypatch.setattr(md.inspect, "getsource", _patched_getsource)
14631468
code = MethodDefinition.build_command_method("/x/y", endpoint)
14641469
assert "def y" in code

0 commit comments

Comments
 (0)