Skip to content

Commit 06bb3f6

Browse files
Another comment sweep.
1 parent d4f87ac commit 06bb3f6

File tree

1 file changed

+75
-37
lines changed

1 file changed

+75
-37
lines changed

server-utils/server_utils/fastapi_utils/fast_build_router.py

+75-37
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""See the `FastBuildRouter` class."""
2+
13
from __future__ import annotations
24

35
import dataclasses
@@ -63,29 +65,52 @@ class _FastAPIRouteMethods:
6365

6466

6567
class FastBuildRouter(_FastAPIRouteMethods):
66-
"""An optimized, stripped-down, drop-in replacement for `fastapi.APIRouter`.
67-
68-
An essential part of the way we organize our code is to have a tree of topic-based
69-
subdirectories that each define their own HTTP routes with a local
70-
`fastapi.APIRouter`, and then combine those into a single `fastapi.FastAPI` app.
71-
72-
Unfortunately, the standard FastAPI way of doing this, with
73-
`APIRouter.include_router()` and `FastAPI.include_router()`, appears to have severe
74-
performance problems. Supposedly, the bad performance has to do with reduntantly
75-
constructing Pydantic objects at each level of nesting
76-
(https://github.com/pydantic/pydantic/issues/6768#issuecomment-1644532429).
77-
This severely impacts server startup time.
78-
79-
This class, a reimplementation of the `fastapi.APIRouter` interface, fixes that.
80-
This gives something like a 1.6x speedup for `import robot_server.app`.
81-
Not all features of `fastapi.APIRouter` are supported,
82-
only the ones that we actually need.
68+
"""An optimized drop-in replacement for `fastapi.APIRouter`.
69+
70+
Use it like `fastapi.APIRouter`:
71+
72+
foo_router = FastBuildRouter()
73+
74+
@router.get("/foo/{id}")
75+
def get_health(id: str) -> Response:
76+
...
77+
78+
bar_router = ...
79+
80+
root_router = FastBuildRouter()
81+
root_router.include_router(foo_router)
82+
root_router.include_router(bar_router)
83+
84+
app = fastapi.FastAPI()
85+
root_router.install_on_app(app)
86+
87+
Rationale:
88+
89+
With FastAPI's standard `FastAPI` and `APIRouter` classes, the `.include_router()`
90+
method has a lot of overhead, accounting for something like 30-40% of
91+
robot-server's startup time, which is multiple minutes long at the time of writing.
92+
(https://github.com/pydantic/pydantic/issues/6768#issuecomment-1644532429)
93+
94+
We could avoid the overhead by adding endpoints directly to the top-level FastAPI
95+
app, "flat," instead of using `.include_router()`. But that would be bad for code
96+
organization; we want to keep our tree of sub-routers. So this class reimplements
97+
the important parts of `fastapi.APIRouter`, so we can keep our router tree, but
98+
in a lighter-weight way.
99+
100+
When you call `@router.get()` or `router.include_router()` on this class, it appends
101+
to a lightweight internal structure and completely avoids slow calls into FastAPI.
102+
Later on, when you do `router.install_on_app()`, everything in the tree is added to
103+
the FastAPI app.
83104
"""
84105

85106
def __init__(self) -> None:
86107
self._routes: list[_Endpoint | _IncludedRouter] = []
87108

88109
def __getattr__(self, name: str) -> object:
110+
"""Supply the optimized version of `@router.get()`, `@router.post()`, etc.
111+
112+
See the FastAPI docs for usage details.
113+
"""
89114
if name in _FASTAPI_ROUTE_METHOD_NAMES:
90115
return _EndpointCaptor(method_name=name, on_capture=self._routes.append)
91116
else:
@@ -96,15 +121,21 @@ def include_router(
96121
router: FastBuildRouter | fastapi.APIRouter,
97122
**kwargs: typing_extensions.Unpack[_RouterIncludeKwargs],
98123
) -> None:
99-
"""The optimized version of `fastapi.APIRouter.include_router()`.""" # noqa: D402
124+
"""The optimized version of `fastapi.APIRouter.include_router()`.
125+
126+
See the FastAPI docs for argument details.
127+
""" # noqa: D402
100128
self._routes.append(_IncludedRouter(router=router, inclusion_kwargs=kwargs))
101129

102130
def install_on_app(
103131
self,
104132
app: fastapi.FastAPI,
105133
**kwargs: typing_extensions.Unpack[_RouterIncludeKwargs],
106134
) -> None:
107-
"""The optimized version of `fastapi.FastAPI.include_router()`."""
135+
"""The optimized version of `fastapi.FastAPI.include_router()`.
136+
137+
See the FastAPI docs for argument details..
138+
"""
108139
for route in self._routes:
109140
if isinstance(route, _IncludedRouter):
110141
router = route.router
@@ -125,13 +156,14 @@ def install_on_app(
125156

126157

127158
class _RouterIncludeKwargs(typing.TypedDict):
128-
"""The keyword arguments of `fastapi.APIRouter.include_router()`.
159+
"""The keyword arguments of FastAPI's `.include_router()` method.
129160
130-
(At least the ones that we care about, anyway.)
161+
(At least the arguments that we actually use, anyway.)
131162
"""
132163

133164
# Arguments with defaults should be annotated as `NotRequired`.
134165
# For example, `foo: str | None = None` becomes `NotRequired[str | None]`.
166+
135167
tags: typing_extensions.NotRequired[list[str | enum.Enum] | None]
136168
responses: typing_extensions.NotRequired[
137169
dict[int | str, dict[str, typing.Any]] | None
@@ -155,7 +187,8 @@ def _merge_kwargs(
155187
For example, the top-level router, subrouters, and finally the endpoint function
156188
can each specify their own `tags`. The different levels need to be merged
157189
carefully and in argument-specific ways if we want to match FastAPI behavior.
158-
For example, `tags` should be the concatenation of all levels.
190+
For example, the final `tags` value should be the concatenation of the values
191+
from all levels.
159192
"""
160193
merge_result: _RouterIncludeKwargs = {}
161194
remaining_from_parent = from_parent.copy()
@@ -208,35 +241,38 @@ class _IncludedRouter:
208241
inclusion_kwargs: _RouterIncludeKwargs
209242

210243

211-
DecoratedFunctionT = typing.TypeVar(
212-
"DecoratedFunctionT", bound=typing.Callable[..., object]
244+
_DecoratedFunctionT = typing.TypeVar(
245+
"_DecoratedFunctionT", bound=typing.Callable[..., object]
213246
)
214247

215248

216249
class _EndpointCaptor:
250+
"""A callable that pretends to be a FastAPI path operation decorator.
251+
252+
`method_name` is the FastAPI method to pretend to be, e.g. "get" or "post".
253+
254+
Supposing you have an `_EndpointCaptor` named `get`, when this whole enchilada
255+
happens:
256+
257+
@get("/foo/{id}", description="blah blah")
258+
def get_some_endpoint(id: str) -> Response:
259+
...
260+
261+
Then information about the whole enchilada is sent to the `on_capture` callback.
262+
"""
263+
217264
def __init__(
218265
self,
219266
method_name: str,
220267
on_capture: typing.Callable[[_Endpoint], None],
221268
) -> None:
222-
"""
223-
Params:
224-
method_name: The name of the method on the fastapi.FastAPI class that this
225-
should proxy, e.g. "get" or "post".
226-
on_capture: Called when we capture a call,
227-
i.e. when some router module does:
228-
229-
@router.get("/foo")
230-
def get_foo() -> FooResponse:
231-
...
232-
"""
233269
self._method_name = method_name
234270
self._on_capture = on_capture
235271

236272
def __call__(
237273
self, *fastapi_decorator_args: object, **fastapi_decorator_kwargs: object
238-
) -> typing.Callable[[DecoratedFunctionT], DecoratedFunctionT]:
239-
def decorate(decorated_function: DecoratedFunctionT) -> DecoratedFunctionT:
274+
) -> typing.Callable[[_DecoratedFunctionT], _DecoratedFunctionT]:
275+
def decorate(decorated_function: _DecoratedFunctionT) -> _DecoratedFunctionT:
240276
self._on_capture(
241277
_Endpoint(
242278
method_name=self._method_name,
@@ -252,6 +288,8 @@ def decorate(decorated_function: DecoratedFunctionT) -> DecoratedFunctionT:
252288

253289
@dataclasses.dataclass
254290
class _Endpoint:
291+
"""Information about an endpoint that's been added to a router."""
292+
255293
method_name: str
256294
"""The name of the method on the FastAPI class, e.g. "get"."""
257295

0 commit comments

Comments
 (0)