fastapi-deferred-init 0.2.12__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.1
2
+ Name: fastapi-deferred-init
3
+ Version: 0.3.1
4
+ Summary: Faster FastAPI start-up time for Projects with many nested routers
5
+ Keywords: fastapi,speed,router,startup,optimization
6
+ Author-Email: Jan Vollmer <jan@vllmr.dev>
7
+ License: MIT
8
+ Classifier: Environment :: Plugins
9
+ Classifier: Framework :: FastAPI
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ Project-URL: Source, https://github.com/jvllmr/fastapi-deferred-init
19
+ Project-URL: Changelog, https://github.com/jvllmr/fastapi-deferred-init/releases
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: fastapi>=0.139.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # fastapi-deferred-init
25
+
26
+ ![PyPI - Downloads](https://img.shields.io/pypi/dd/fastapi-deferred-init)
27
+ [![GitHub license](https://img.shields.io/github/license/jvllmr/fastapi-deferred-init)](https://github.com/jvllmr/fastapi-deferred-init/blob/master/LICENSE)
28
+ ![Tests](https://github.com/jvllmr/fastapi-deferred-init/actions/workflows/test.yml/badge.svg)
29
+
30
+ ## The Problem
31
+
32
+ When using nested routers in a FastAPI project its start-up time can get long quite fast.
33
+ That is because every router re-calculates the routes defined by a nested router when including it and the pre-calculated values by the nested router never get used. In short: values in nested routers are calculated although they will never be used.
34
+
35
+ ## The Solution
36
+
37
+ This library provides a modified APIRoute that defers the calculation of values to the first actual attribute access. A router which uses the route as a default is also provided.
38
+
39
+ ## Usage
40
+
41
+ Older versions of this library worked by simply using custom APIRoute and APIRouter classes, but since FastAPI version 0.137.0 the optimizations require monkey patching to take effect. The following example shows how the patches can be applied by creating an `asgi.py` file as your entrypoint:
42
+
43
+ ```python
44
+ # my_app/asgi.py
45
+ from fastapi_deferred_init import apply_patch
46
+
47
+ # important! don't import anything else before this to make sure the patch works as expected
48
+ apply_patch()
49
+
50
+ # run your app with i.e. gunicorn my_app.asgi:app
51
+ from my_app.app import app
52
+
53
+ # mark app as re-export
54
+ __all__ = ["app"]
55
+ ```
56
+
57
+ ## Caveat
58
+
59
+ When using the deferred APIRoute on every layer of the app, startup errors are deferred to the first time a route is called. So errors related to route registration might go undetected if the route is not tested at least once.
60
+
61
+ ## Pull Request
62
+
63
+ I created a pull request to merge this change back into FastAPI: https://github.com/tiangolo/fastapi/pull/10589
64
+ It was closed since major refactors to FastAPI are underway, but I might re-open it once the maintainers are ready.
@@ -1,16 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: fastapi-deferred-init
3
- Version: 0.2.12
4
- Summary: Faster FastAPI start-up time for Projects with many nested routers
5
- Keywords: fastapi,speed,router
6
- Author-Email: Jan Vollmer <jan@vllmr.dev>
7
- License: MIT
8
- Classifier: Framework :: FastAPI
9
- Project-URL: Source, https://github.com/jvllmr/fastapi-deferred-init
10
- Requires-Python: >=3.10
11
- Requires-Dist: fastapi>=0.132.0
12
- Description-Content-Type: text/markdown
13
-
14
1
  # fastapi-deferred-init
15
2
 
16
3
  ![PyPI - Downloads](https://img.shields.io/pypi/dd/fastapi-deferred-init)
@@ -26,6 +13,24 @@ That is because every router re-calculates the routes defined by a nested router
26
13
 
27
14
  This library provides a modified APIRoute that defers the calculation of values to the first actual attribute access. A router which uses the route as a default is also provided.
28
15
 
16
+ ## Usage
17
+
18
+ Older versions of this library worked by simply using custom APIRoute and APIRouter classes, but since FastAPI version 0.137.0 the optimizations require monkey patching to take effect. The following example shows how the patches can be applied by creating an `asgi.py` file as your entrypoint:
19
+
20
+ ```python
21
+ # my_app/asgi.py
22
+ from fastapi_deferred_init import apply_patch
23
+
24
+ # important! don't import anything else before this to make sure the patch works as expected
25
+ apply_patch()
26
+
27
+ # run your app with i.e. gunicorn my_app.asgi:app
28
+ from my_app.app import app
29
+
30
+ # mark app as re-export
31
+ __all__ = ["app"]
32
+ ```
33
+
29
34
  ## Caveat
30
35
 
31
36
  When using the deferred APIRoute on every layer of the app, startup errors are deferred to the first time a route is called. So errors related to route registration might go undetected if the route is not tested at least once.
@@ -33,3 +38,4 @@ When using the deferred APIRoute on every layer of the app, startup errors are d
33
38
  ## Pull Request
34
39
 
35
40
  I created a pull request to merge this change back into FastAPI: https://github.com/tiangolo/fastapi/pull/10589
41
+ It was closed since major refactors to FastAPI are underway, but I might re-open it once the maintainers are ready.
@@ -5,26 +5,38 @@ keywords = [
5
5
  "fastapi",
6
6
  "speed",
7
7
  "router",
8
+ "startup",
9
+ "optimization",
8
10
  ]
9
11
  authors = [
10
12
  { name = "Jan Vollmer", email = "jan@vllmr.dev" },
11
13
  ]
12
14
  dependencies = [
13
- "fastapi>=0.132.0",
15
+ "fastapi>=0.139.0",
14
16
  ]
15
17
  requires-python = ">=3.10"
16
18
  readme = "README.md"
17
19
  classifiers = [
20
+ "Environment :: Plugins",
18
21
  "Framework :: FastAPI",
22
+ "Intended Audience :: Developers",
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
29
+ "Typing :: Typed",
19
30
  ]
20
31
  dynamic = []
21
- version = "0.2.12"
32
+ version = "0.3.1"
22
33
 
23
34
  [project.license]
24
35
  text = "MIT"
25
36
 
26
37
  [project.urls]
27
38
  Source = "https://github.com/jvllmr/fastapi-deferred-init"
39
+ Changelog = "https://github.com/jvllmr/fastapi-deferred-init/releases"
28
40
 
29
41
  [build-system]
30
42
  requires = [
@@ -73,13 +85,13 @@ all = true
73
85
 
74
86
  [dependency-groups]
75
87
  dev = [
76
- "pytest>=8.4.2",
77
- "ruff>=0.15.2",
88
+ "pytest>=9.1.1",
89
+ "ruff>=0.15.20",
78
90
  "pre-commit>=4.3.0",
79
- "mypy>=1.19.1",
80
- "tuna>=0.5.11",
81
- "httpx>=0.28.1",
91
+ "mypy>=2.1.0",
92
+ "tuna>=0.5.15",
82
93
  "pytest-benchmark>=5.2.3",
83
- "pip>=26.0.1",
84
- "inline-snapshot>=0.32.2",
94
+ "pip>=26.1.2",
95
+ "inline-snapshot>=0.34.2",
96
+ "httpx2>=2.9.1",
85
97
  ]
@@ -0,0 +1,3 @@
1
+ from .patch import apply_patch
2
+
3
+ __all__ = ["apply_patch"]
@@ -0,0 +1,14 @@
1
+ from .routing import _populate_api_route_state, DeferringAPIRoute, DeferringAPIRouter
2
+
3
+
4
+ def apply_patch() -> None:
5
+
6
+ from fastapi import routing # type: ignore[attr-defined]
7
+
8
+ routing._populate_api_route_state = _populate_api_route_state # ty:ignore[invalid-assignment]
9
+ routing.APIRoute = DeferringAPIRoute # ty:ignore[invalid-assignment]
10
+ routing.APIRouter = DeferringAPIRouter # ty:ignore[invalid-assignment]
11
+
12
+ import fastapi
13
+
14
+ fastapi.APIRouter = DeferringAPIRouter # type:ignore[attr-defined] # ty:ignore[invalid-assignment]
@@ -0,0 +1,272 @@
1
+ from fastapi.dependencies.models import Dependant
2
+ import inspect
3
+ from enum import Enum, IntEnum
4
+ from functools import cached_property
5
+ from typing import Any, Callable, Union, cast
6
+ from collections.abc import Sequence
7
+
8
+
9
+ from fastapi.sse import (
10
+ EventSourceResponse,
11
+ ServerSentEvent,
12
+ )
13
+ from fastapi import params, routing # type: ignore[attr-defined]
14
+ from fastapi._compat import ModelField, lenient_issubclass
15
+ from fastapi.datastructures import Default, DefaultPlaceholder
16
+ from fastapi.dependencies.utils import (
17
+ _should_embed_body_fields,
18
+ get_body_field,
19
+ get_dependant,
20
+ get_flat_dependant,
21
+ get_parameterless_sub_dependant,
22
+ get_typed_return_annotation,
23
+ get_stream_item_type,
24
+ )
25
+ from fastapi.responses import JSONResponse, Response
26
+ from fastapi.types import IncEx
27
+ from fastapi.utils import (
28
+ create_model_field,
29
+ generate_unique_id,
30
+ is_body_allowed_for_status_code,
31
+ )
32
+
33
+
34
+ def _add_cache_attribute(
35
+ instance: routing._APIRouteLike, name: str, func: Callable[[Any], Any]
36
+ ) -> None:
37
+ cls = type(instance)
38
+
39
+ if name in instance.__dict__ and (
40
+ (val := instance.__dict__[name]) is None or val is False
41
+ ):
42
+ delattr(instance, name)
43
+
44
+ # replace attribute with cached_property
45
+ if not hasattr(cls, name) or not isinstance(getattr(cls, name), cached_property):
46
+ prop = cached_property(func)
47
+ setattr(cls, name, prop)
48
+ prop.__set_name__(cls, name)
49
+
50
+
51
+ def _populate_api_route_state(
52
+ route: routing._APIRouteLike,
53
+ path: str,
54
+ endpoint: Callable[..., Any],
55
+ *,
56
+ response_model: Any = Default(None),
57
+ status_code: int | None = None,
58
+ tags: list[str | Enum] | None = None,
59
+ dependencies: Sequence[params.Depends] | None = None,
60
+ summary: str | None = None,
61
+ description: str | None = None,
62
+ response_description: str = "Successful Response",
63
+ responses: dict[int | str, dict[str, Any]] | None = None,
64
+ deprecated: bool | None = None,
65
+ name: str | None = None,
66
+ methods: set[str] | list[str] | None = None,
67
+ operation_id: str | None = None,
68
+ response_model_include: IncEx | None = None,
69
+ response_model_exclude: IncEx | None = None,
70
+ response_model_by_alias: bool = True,
71
+ response_model_exclude_unset: bool = False,
72
+ response_model_exclude_defaults: bool = False,
73
+ response_model_exclude_none: bool = False,
74
+ include_in_schema: bool = True,
75
+ response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse),
76
+ dependency_overrides_provider: Any | None = None,
77
+ callbacks: list[routing.BaseRoute] | None = None,
78
+ openapi_extra: dict[str, Any] | None = None,
79
+ generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default(
80
+ generate_unique_id
81
+ ),
82
+ strict_content_type: bool | DefaultPlaceholder = Default(True),
83
+ ) -> None:
84
+ route.path = path
85
+ route.endpoint = endpoint
86
+ route.stream_item_type = None
87
+ if isinstance(response_model, DefaultPlaceholder):
88
+ return_annotation = get_typed_return_annotation(endpoint)
89
+ if lenient_issubclass(return_annotation, Response):
90
+ response_model = None
91
+ else:
92
+ stream_item = get_stream_item_type(return_annotation)
93
+ if stream_item is not None:
94
+ # Extract item type for JSONL or SSE streaming when
95
+ # response_class is DefaultPlaceholder (JSONL) or
96
+ # EventSourceResponse (SSE).
97
+ # ServerSentEvent is excluded: it's a transport
98
+ # wrapper, not a data model, so it shouldn't feed
99
+ # into validation or OpenAPI schema generation.
100
+ if (
101
+ isinstance(response_class, DefaultPlaceholder)
102
+ or lenient_issubclass(response_class, EventSourceResponse)
103
+ ) and not lenient_issubclass(stream_item, ServerSentEvent):
104
+ route.stream_item_type = stream_item
105
+ response_model = None
106
+ else:
107
+ response_model = return_annotation
108
+ route.response_model = response_model
109
+ route.summary = summary
110
+ route.response_description = response_description
111
+ route.deprecated = deprecated
112
+ route.operation_id = operation_id
113
+ route.response_model_include = response_model_include
114
+ route.response_model_exclude = response_model_exclude
115
+ route.response_model_by_alias = response_model_by_alias
116
+ route.response_model_exclude_unset = response_model_exclude_unset
117
+ route.response_model_exclude_defaults = response_model_exclude_defaults
118
+ route.response_model_exclude_none = response_model_exclude_none
119
+ route.include_in_schema = include_in_schema
120
+ route.response_class = response_class
121
+ route.dependency_overrides_provider = dependency_overrides_provider
122
+ route.callbacks = callbacks
123
+ route.openapi_extra = openapi_extra
124
+ route.generate_unique_id_function = generate_unique_id_function
125
+ route.strict_content_type = strict_content_type
126
+ route.tags = tags or []
127
+ route.responses = responses or {}
128
+ route.name = routing.get_name(endpoint) if name is None else name
129
+ route.path_regex, route.path_format, route.param_convertors = routing.compile_path(
130
+ path
131
+ )
132
+ if methods is None:
133
+ methods = ["GET"]
134
+ route.methods = {method.upper() for method in methods}
135
+ if isinstance(generate_unique_id_function, DefaultPlaceholder):
136
+ current_generate_unique_id: Callable[[Any], str] = (
137
+ generate_unique_id_function.value
138
+ )
139
+ else:
140
+ current_generate_unique_id = generate_unique_id_function
141
+ route.unique_id = route.operation_id or current_generate_unique_id(route)
142
+ # normalize enums e.g. http.HTTPStatus
143
+ if isinstance(status_code, IntEnum):
144
+ status_code = int(status_code)
145
+ route.status_code = status_code
146
+ if route.response_model:
147
+ assert is_body_allowed_for_status_code(status_code), (
148
+ f"Status code {status_code} must not have a response body"
149
+ )
150
+
151
+ def _response_field(self):
152
+ if self.response_model:
153
+ response_name = "Response_" + self.unique_id
154
+ return create_model_field(
155
+ name=response_name,
156
+ type_=self.response_model,
157
+ mode="serialization",
158
+ )
159
+ else:
160
+ return None
161
+
162
+ _add_cache_attribute(route, "response_field", _response_field)
163
+
164
+ def _stream_item_field(self) -> ModelField | None:
165
+ if not self.stream_item_type:
166
+ return None
167
+ stream_item_name = "StreamItem_" + self.unique_id
168
+ return create_model_field(
169
+ name=stream_item_name,
170
+ type_=self.stream_item_type,
171
+ mode="serialization",
172
+ )
173
+
174
+ _add_cache_attribute(route, "stream_item_field", _stream_item_field)
175
+
176
+ route.dependencies = list(dependencies or [])
177
+ route.description = description or inspect.cleandoc(route.endpoint.__doc__ or "")
178
+ # if a "form feed" character (page break) is found in the description text,
179
+ # truncate description text to the content preceding the first "form feed"
180
+ route.description = route.description.split("\f")[0].strip()
181
+
182
+ def _response_fields(self) -> dict[Union[int, str], ModelField]:
183
+ response_fields: dict[Union[int, str], ModelField] = {}
184
+ for additional_status_code, response in self.responses.items():
185
+ assert isinstance(response, dict), "An additional response must be a dict"
186
+ model = response.get("model")
187
+ if model:
188
+ assert is_body_allowed_for_status_code(additional_status_code), (
189
+ f"Status code {additional_status_code} must not have a response body"
190
+ )
191
+ response_name = f"Response_{additional_status_code}_{self.unique_id}"
192
+ response_field = create_model_field(
193
+ name=response_name, type_=model, mode="serialization"
194
+ )
195
+ response_fields[additional_status_code] = response_field
196
+ return response_fields
197
+
198
+ _add_cache_attribute(route, "response_fields", _response_fields)
199
+
200
+ assert callable(endpoint), "An endpoint must be a callable"
201
+
202
+ def _get_dependant(self: routing._APIRouteLike) -> Dependant:
203
+ dependant = get_dependant(
204
+ path=self.path_format, call=self.endpoint, scope="function"
205
+ )
206
+
207
+ for depends in self.dependencies[::-1]:
208
+ dependant.dependencies.insert(
209
+ 0,
210
+ get_parameterless_sub_dependant(depends=depends, path=self.path_format),
211
+ )
212
+
213
+ return dependant
214
+
215
+ _add_cache_attribute(route, "dependant", _get_dependant)
216
+
217
+ def _get_flat_dependant(self) -> Dependant:
218
+ return get_flat_dependant(self.dependant)
219
+
220
+ _add_cache_attribute(route, "_flat_dependant", _get_flat_dependant)
221
+
222
+ def _embed_body_fields(self):
223
+ return _should_embed_body_fields(self._flat_dependant.body_params)
224
+
225
+ _add_cache_attribute(route, "_embed_body_fields", _embed_body_fields)
226
+
227
+ def _body_field(self):
228
+ return get_body_field(
229
+ flat_dependant=self._flat_dependant,
230
+ name=self.unique_id,
231
+ embed_body_fields=self._embed_body_fields,
232
+ )
233
+
234
+ _add_cache_attribute(route, "body_field", _body_field)
235
+
236
+ # Detect generator endpoints that should stream as JSONL or SSE
237
+ def _is_generator(self) -> bool:
238
+ return self.dependant.is_async_gen_callable or self.dependant.is_gen_callable
239
+
240
+ _add_cache_attribute(route, "is_generator", _is_generator)
241
+
242
+ def _is_sse_stream(self) -> bool:
243
+ return self.is_generator and lenient_issubclass(
244
+ self.response_class, EventSourceResponse
245
+ )
246
+
247
+ _add_cache_attribute(route, "is_sse_stream", _is_sse_stream)
248
+
249
+ def _is_json_stream(self) -> bool:
250
+ return self.is_generator and isinstance(self.response_class, DefaultPlaceholder)
251
+
252
+ _add_cache_attribute(route, "is_json_stream", _is_json_stream)
253
+
254
+
255
+ class DeferringAPIRoute(routing.APIRoute):
256
+ def __init__(
257
+ self, path: str, endpoint: Callable[..., Any], *args, **kwargs
258
+ ) -> None:
259
+ _populate_api_route_state(
260
+ cast(routing._APIRouteLike, self), path, endpoint, *args, **kwargs
261
+ )
262
+
263
+ @cached_property
264
+ def app(self):
265
+ return routing.request_response(self.get_route_handler())
266
+
267
+
268
+ class DeferringAPIRouter(routing.APIRouter):
269
+ def __init__(self, *args, **kwargs):
270
+ if "route_class" not in kwargs:
271
+ kwargs["route_class"] = DeferringAPIRoute
272
+ super().__init__(*args, **kwargs)
@@ -13,11 +13,6 @@ base_module = ast.Module(
13
13
  names=[ast.alias(name="JSONResponse")],
14
14
  level=0,
15
15
  ),
16
- ast.ImportFrom(
17
- module="fastapi_deferred_init",
18
- names=[ast.alias(name="DeferringAPIRouter")],
19
- level=0,
20
- ),
21
16
  ast.Assign(
22
17
  targets=[ast.Name(id="dependency0", ctx=ast.Store())],
23
18
  value=ast.Lambda(
@@ -58,14 +53,12 @@ def gen_func_def(n=1, last=False):
58
53
  )
59
54
 
60
55
 
61
- def gen_router(n=2, last=False, use_lib=True):
56
+ def gen_router(n=2, last=False):
62
57
  return [
63
58
  ast.Assign(
64
59
  targets=[ast.Name(id=f"router{'' if last else n}", ctx=ast.Store())],
65
60
  value=ast.Call(
66
- func=ast.Name(
67
- id="DeferringAPIRouter" if use_lib else "APIRouter", ctx=ast.Load()
68
- ),
61
+ func=ast.Name(id="APIRouter", ctx=ast.Load()),
69
62
  args=[],
70
63
  keywords=[
71
64
  ast.keyword(
@@ -151,7 +144,9 @@ def gen_router(n=2, last=False, use_lib=True):
151
144
  )
152
145
 
153
146
 
154
- def get_tree(n=1000, use_lib=True):
147
+ def get_tree(
148
+ n=1000,
149
+ ):
155
150
  module = copy.deepcopy(base_module)
156
151
  for i in range(1, n + 1):
157
152
  module.body.append(gen_func_def(i))
@@ -159,16 +154,24 @@ def get_tree(n=1000, use_lib=True):
159
154
  module.body.append(gen_func_def(i + 1, True))
160
155
 
161
156
  for i in range(n):
162
- module.body.extend(gen_router(i, use_lib=use_lib))
157
+ module.body.extend(
158
+ gen_router(
159
+ i,
160
+ )
161
+ )
163
162
 
164
- module.body.extend(gen_router(i + 1, True, use_lib))
163
+ module.body.extend(gen_router(i + 1, True))
165
164
 
166
165
  ast.fix_missing_locations(module)
167
166
  return module
168
167
 
169
168
 
170
- def create_code(n=1000, use_lib=True):
171
- tree = get_tree(n, use_lib)
169
+ def create_code(
170
+ n=1000,
171
+ ):
172
+ tree = get_tree(
173
+ n,
174
+ )
172
175
 
173
176
  with open("tests/data/code.py", "w") as f:
174
177
  f.write(ast.unparse(tree))
@@ -2,12 +2,15 @@ import os
2
2
 
3
3
  import pytest
4
4
  from pydantic import BaseModel
5
- from starlette.routing import Route
6
5
 
7
- from fastapi import APIRouter, FastAPI
8
- from fastapi.routing import APIRoute
6
+ from fastapi import FastAPI, APIRouter
7
+ from fastapi import routing
9
8
  from fastapi.testclient import TestClient
10
- from fastapi_deferred_init import DeferringAPIRoute, DeferringAPIRouter
9
+ from fastapi_deferred_init.routing import (
10
+ _populate_api_route_state,
11
+ DeferringAPIRoute,
12
+ DeferringAPIRouter,
13
+ )
11
14
 
12
15
  from .data.gen_code_ast import create_code
13
16
  from .helpers import import_via_file_path, load_code
@@ -18,34 +21,52 @@ def skip_test(use_lib: bool):
18
21
  pytest.skip()
19
22
 
20
23
 
24
+ def apply_monkeypatch(monkeypatch):
25
+ monkeypatch.setattr(
26
+ "fastapi.routing._populate_api_route_state", _populate_api_route_state
27
+ )
28
+ monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
29
+ monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
30
+ monkeypatch.setattr("fastapi.APIRouter", DeferringAPIRouter)
31
+
32
+
21
33
  @pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
22
- def test_basic(use_lib: bool, benchmark):
34
+ def test_basic(use_lib: bool, benchmark, monkeypatch):
23
35
  skip_test(use_lib=use_lib)
24
- create_code(50, use_lib=use_lib) # switch bool to compare
36
+ if use_lib:
37
+ apply_monkeypatch(monkeypatch)
38
+ create_code(
39
+ 50,
40
+ )
25
41
 
26
42
  generated_code = benchmark(load_code)
27
-
43
+ assert generated_code.APIRouter is (DeferringAPIRouter if use_lib else APIRouter)
28
44
  app = FastAPI()
29
45
  router = generated_code.router
30
46
 
31
47
  app.include_router(router)
32
48
 
33
- assert type(router) is (DeferringAPIRouter if use_lib else APIRouter)
34
49
  client = TestClient(app)
35
- assert len(app.routes) == 54
36
- for route in app.routes:
37
- if route in router.routes:
38
- assert type(route) is (DeferringAPIRoute if use_lib else APIRoute)
39
- assert isinstance(route, Route)
50
+ routes = list(routing.iter_route_contexts(app.routes))
51
+ assert len(routes) == 54
52
+ for route in routes:
53
+ if not route.path or route.path in ("/openapi.json"):
54
+ continue
40
55
  resp = client.get(route.path)
41
56
  assert resp.status_code == 200
42
57
 
43
58
 
44
59
  @pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
45
- def test_with_pydantic_model(use_lib: bool):
60
+ def test_with_pydantic_model(use_lib: bool, monkeypatch):
61
+
46
62
  skip_test(use_lib=use_lib)
63
+ if use_lib:
64
+ apply_monkeypatch(monkeypatch)
47
65
  app = FastAPI()
48
- router = DeferringAPIRouter() if use_lib else APIRouter()
66
+ router = routing.APIRouter()
67
+ assert isinstance(router, DeferringAPIRouter if use_lib else routing.APIRouter), (
68
+ type(router)
69
+ )
49
70
 
50
71
  class Login(BaseModel):
51
72
  username: str
@@ -59,6 +80,12 @@ def test_with_pydantic_model(use_lib: bool):
59
80
 
60
81
  return {"userdata": "[...]"}
61
82
 
83
+ assert isinstance(
84
+ router.routes[0], DeferringAPIRoute if use_lib else routing.APIRoute
85
+ ), (
86
+ router.routes,
87
+ type(router.routes[0]),
88
+ )
62
89
  app.include_router(router)
63
90
 
64
91
  client = TestClient(app)
@@ -68,8 +95,8 @@ def test_with_pydantic_model(use_lib: bool):
68
95
 
69
96
 
70
97
  def test_fastapi_openapi_schema(monkeypatch):
71
- monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
72
- monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
98
+ apply_monkeypatch(monkeypatch)
99
+
73
100
  import_via_file_path(
74
101
  "fastapi_clone.tests.test_additional_properties",
75
102
  "fastapi/tests/test_additional_properties.py",
@@ -92,8 +119,7 @@ def test_fastapi_openapi_schema(monkeypatch):
92
119
 
93
120
 
94
121
  def test_fastapi_sse(monkeypatch):
95
- monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
96
- monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
122
+ apply_monkeypatch(monkeypatch)
97
123
  import_via_file_path(
98
124
  "fastapi_clone.tests.test_sse",
99
125
  "fastapi/tests/test_sse.py",
@@ -1,22 +0,0 @@
1
- # fastapi-deferred-init
2
-
3
- ![PyPI - Downloads](https://img.shields.io/pypi/dd/fastapi-deferred-init)
4
- [![GitHub license](https://img.shields.io/github/license/jvllmr/fastapi-deferred-init)](https://github.com/jvllmr/fastapi-deferred-init/blob/master/LICENSE)
5
- ![Tests](https://github.com/jvllmr/fastapi-deferred-init/actions/workflows/test.yml/badge.svg)
6
-
7
- ## The Problem
8
-
9
- When using nested routers in a FastAPI project its start-up time can get long quite fast.
10
- That is because every router re-calculates the routes defined by a nested router when including it and the pre-calculated values by the nested router never get used. In short: values in nested routers are calculated although they will never be used.
11
-
12
- ## The Solution
13
-
14
- This library provides a modified APIRoute that defers the calculation of values to the first actual attribute access. A router which uses the route as a default is also provided.
15
-
16
- ## Caveat
17
-
18
- When using the deferred APIRoute on every layer of the app, startup errors are deferred to the first time a route is called. So errors related to route registration might go undetected if the route is not tested at least once.
19
-
20
- ## Pull Request
21
-
22
- I created a pull request to merge this change back into FastAPI: https://github.com/tiangolo/fastapi/pull/10589
@@ -1,3 +0,0 @@
1
- from .routing import DeferringAPIRoute, DeferringAPIRouter
2
-
3
- __all__ = ["DeferringAPIRoute", "DeferringAPIRouter"]
@@ -1,283 +0,0 @@
1
- import inspect
2
- from enum import Enum, IntEnum
3
- from functools import cached_property
4
- from typing import (
5
- Any,
6
- Callable,
7
- ClassVar,
8
- Optional,
9
- Union,
10
- )
11
- from collections.abc import Sequence
12
-
13
- from starlette.routing import BaseRoute
14
- from starlette.routing import Mount as Mount # noqa
15
- from starlette.routing import compile_path, get_name
16
- from starlette.types import ASGIApp, Lifespan
17
-
18
- from fastapi.sse import (
19
- EventSourceResponse,
20
- ServerSentEvent,
21
- )
22
- from fastapi import params, routing # type:ignore
23
- from fastapi._compat import ModelField, lenient_issubclass
24
- from fastapi.datastructures import Default, DefaultPlaceholder
25
- from fastapi.dependencies.utils import (
26
- _should_embed_body_fields,
27
- get_body_field,
28
- get_dependant,
29
- get_flat_dependant,
30
- get_parameterless_sub_dependant,
31
- get_typed_return_annotation,
32
- get_stream_item_type,
33
- )
34
- from fastapi.responses import JSONResponse, Response
35
- from fastapi.types import IncEx
36
- from fastapi.utils import (
37
- create_model_field,
38
- generate_unique_id,
39
- is_body_allowed_for_status_code,
40
- )
41
-
42
-
43
- class DeferringAPIRoute(routing.APIRoute):
44
- _getattr: ClassVar[Callable[[Any, str], Any]]
45
-
46
- def __init__(
47
- self,
48
- path: str,
49
- endpoint: Callable[..., Any],
50
- *,
51
- response_model: Any = Default(None),
52
- status_code: int | None = None,
53
- tags: list[str | Enum] | None = None,
54
- dependencies: Sequence[params.Depends] | None = None,
55
- summary: str | None = None,
56
- description: str | None = None,
57
- response_description: str = "Successful Response",
58
- responses: dict[int | str, dict[str, Any]] | None = None,
59
- deprecated: bool | None = None,
60
- name: str | None = None,
61
- methods: set[str] | list[str] | None = None,
62
- operation_id: str | None = None,
63
- response_model_include: IncEx | None = None,
64
- response_model_exclude: IncEx | None = None,
65
- response_model_by_alias: bool = True,
66
- response_model_exclude_unset: bool = False,
67
- response_model_exclude_defaults: bool = False,
68
- response_model_exclude_none: bool = False,
69
- include_in_schema: bool = True,
70
- response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse),
71
- dependency_overrides_provider: Any | None = None,
72
- callbacks: list[BaseRoute] | None = None,
73
- openapi_extra: dict[str, Any] | None = None,
74
- generate_unique_id_function: Callable[[routing.APIRoute], str]
75
- | DefaultPlaceholder = Default(generate_unique_id),
76
- strict_content_type: bool | DefaultPlaceholder = Default(True),
77
- ) -> None:
78
- self.path = path
79
- self.endpoint = endpoint
80
- self.stream_item_type: Any | None = None
81
- if isinstance(response_model, DefaultPlaceholder):
82
- return_annotation = get_typed_return_annotation(endpoint)
83
- if lenient_issubclass(return_annotation, Response):
84
- response_model = None
85
- else:
86
- stream_item = get_stream_item_type(return_annotation)
87
- if stream_item is not None:
88
- # Extract item type for JSONL or SSE streaming when
89
- # response_class is DefaultPlaceholder (JSONL) or
90
- # EventSourceResponse (SSE).
91
- # ServerSentEvent is excluded: it's a transport
92
- # wrapper, not a data model, so it shouldn't feed
93
- # into validation or OpenAPI schema generation.
94
- if (
95
- isinstance(response_class, DefaultPlaceholder)
96
- or lenient_issubclass(response_class, EventSourceResponse)
97
- ) and not lenient_issubclass(stream_item, ServerSentEvent):
98
- self.stream_item_type = stream_item
99
- response_model = None
100
- else:
101
- response_model = return_annotation
102
- self.response_model = response_model
103
- self.summary = summary
104
- self.response_description = response_description
105
- self.deprecated = deprecated
106
- self.operation_id = operation_id
107
- self.response_model_include = response_model_include
108
- self.response_model_exclude = response_model_exclude
109
- self.response_model_by_alias = response_model_by_alias
110
- self.response_model_exclude_unset = response_model_exclude_unset
111
- self.response_model_exclude_defaults = response_model_exclude_defaults
112
- self.response_model_exclude_none = response_model_exclude_none
113
- self.include_in_schema = include_in_schema
114
- self.response_class = response_class
115
- self.dependency_overrides_provider = dependency_overrides_provider
116
- self.callbacks = callbacks
117
- self.openapi_extra = openapi_extra
118
- self.generate_unique_id_function = generate_unique_id_function
119
- self.strict_content_type = strict_content_type
120
- self.tags = tags or []
121
- self.responses = responses or {}
122
- self.name = get_name(endpoint) if name is None else name
123
- self.path_regex, self.path_format, self.param_convertors = compile_path(path)
124
- if methods is None:
125
- methods = ["GET"]
126
- self.methods: set[str] = {method.upper() for method in methods}
127
- if isinstance(generate_unique_id_function, DefaultPlaceholder):
128
- current_generate_unique_id: Callable[[routing.APIRoute], str] = (
129
- generate_unique_id_function.value
130
- )
131
- else:
132
- current_generate_unique_id = generate_unique_id_function
133
- self.unique_id = self.operation_id or current_generate_unique_id(self)
134
- # normalize enums e.g. http.HTTPStatus
135
- if isinstance(status_code, IntEnum):
136
- status_code = int(status_code)
137
- self.status_code = status_code
138
- if self.response_model:
139
- assert is_body_allowed_for_status_code(status_code), (
140
- f"Status code {status_code} must not have a response body"
141
- )
142
-
143
- self.dependencies = list(dependencies or [])
144
- self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
145
- # if a "form feed" character (page break) is found in the description text,
146
- # truncate description text to the content preceding the first "form feed"
147
- self.description = self.description.split("\f")[0].strip()
148
-
149
- assert callable(endpoint), "An endpoint must be a callable"
150
-
151
- @cached_property
152
- def dependant(self):
153
- dependant = get_dependant(
154
- path=self.path_format, call=self.endpoint, scope="function"
155
- )
156
-
157
- for depends in self.dependencies[::-1]:
158
- dependant.dependencies.insert(
159
- 0,
160
- get_parameterless_sub_dependant(depends=depends, path=self.path_format),
161
- )
162
- return dependant
163
-
164
- @property
165
- def is_generator(self) -> bool:
166
- return self.dependant.is_async_gen_callable or self.dependant.is_gen_callable
167
-
168
- @property
169
- def is_sse_stream(self) -> bool:
170
- return self.is_generator and lenient_issubclass(
171
- self.response_class, EventSourceResponse
172
- )
173
-
174
- @property
175
- def is_json_stream(self) -> bool:
176
- return self.is_generator and isinstance(self.response_class, DefaultPlaceholder)
177
-
178
- @cached_property
179
- def _flat_dependant(self):
180
- return get_flat_dependant(self.dependant)
181
-
182
- @cached_property
183
- def _embed_body_fields(self):
184
- return _should_embed_body_fields(self._flat_dependant.body_params)
185
-
186
- @cached_property
187
- def response_field(self):
188
- if self.response_model:
189
- response_name = "Response_" + self.unique_id
190
- return create_model_field(
191
- name=response_name,
192
- type_=self.response_model,
193
- mode="serialization",
194
- )
195
- else:
196
- return None
197
-
198
- @cached_property
199
- def stream_item_field(self) -> ModelField | None:
200
- if not self.stream_item_type:
201
- return None
202
- stream_item_name = "StreamItem_" + self.unique_id
203
- return create_model_field(
204
- name=stream_item_name,
205
- type_=self.stream_item_type,
206
- mode="serialization",
207
- )
208
-
209
- @cached_property
210
- def response_fields(self) -> dict[Union[int, str], ModelField]:
211
- response_fields: dict[Union[int, str], ModelField] = {}
212
- for additional_status_code, response in self.responses.items():
213
- assert isinstance(response, dict), "An additional response must be a dict"
214
- model = response.get("model")
215
- if model:
216
- assert is_body_allowed_for_status_code(additional_status_code), (
217
- f"Status code {additional_status_code} must not have a response body"
218
- )
219
- response_name = f"Response_{additional_status_code}_{self.unique_id}"
220
- response_field = create_model_field(
221
- name=response_name, type_=model, mode="serialization"
222
- )
223
- response_fields[additional_status_code] = response_field
224
- return response_fields
225
-
226
- @cached_property
227
- def body_field(self):
228
- return get_body_field(
229
- flat_dependant=self._flat_dependant,
230
- name=self.unique_id,
231
- embed_body_fields=self._embed_body_fields,
232
- )
233
-
234
- @cached_property
235
- def app(self):
236
- return routing.request_response(self.get_route_handler())
237
-
238
-
239
- class DeferringAPIRouter(routing.APIRouter):
240
- def __init__(
241
- self,
242
- *,
243
- prefix: str = "",
244
- tags: Optional[list[Union[str, Enum]]] = None,
245
- dependencies: Optional[Sequence[params.Depends]] = None,
246
- default_response_class: type[Response] = Default(JSONResponse),
247
- responses: Optional[dict[Union[int, str], dict[str, Any]]] = None,
248
- callbacks: Optional[list[BaseRoute]] = None,
249
- routes: Optional[list[BaseRoute]] = None,
250
- redirect_slashes: bool = True,
251
- default: Optional[ASGIApp] = None,
252
- dependency_overrides_provider: Optional[Any] = None,
253
- route_class: type[routing.APIRoute] = DeferringAPIRoute,
254
- on_startup: Optional[Sequence[Callable[[], Any]]] = None,
255
- on_shutdown: Optional[Sequence[Callable[[], Any]]] = None,
256
- lifespan: Optional[Lifespan[Any]] = None,
257
- deprecated: Optional[bool] = None,
258
- include_in_schema: bool = True,
259
- generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
260
- generate_unique_id
261
- ),
262
- strict_content_type: bool = Default(True),
263
- ) -> None:
264
- super().__init__(
265
- prefix=prefix,
266
- tags=tags,
267
- dependencies=dependencies,
268
- default_response_class=default_response_class,
269
- responses=responses,
270
- callbacks=callbacks,
271
- routes=routes,
272
- redirect_slashes=redirect_slashes,
273
- default=default,
274
- dependency_overrides_provider=dependency_overrides_provider,
275
- route_class=route_class,
276
- on_startup=on_startup,
277
- on_shutdown=on_shutdown,
278
- lifespan=lifespan,
279
- deprecated=deprecated,
280
- include_in_schema=include_in_schema,
281
- generate_unique_id_function=generate_unique_id_function,
282
- strict_content_type=strict_content_type,
283
- )