fastapi-deferred-init 0.2.12__tar.gz → 0.3.0__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.0
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.137.2
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.137.2",
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.0"
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.0",
89
+ "ruff>=0.15.17",
78
90
  "pre-commit>=4.3.0",
79
- "mypy>=1.19.1",
80
- "tuna>=0.5.11",
91
+ "mypy>=1.20.2",
92
+ "tuna>=0.5.15",
81
93
  "httpx>=0.28.1",
82
94
  "pytest-benchmark>=5.2.3",
83
- "pip>=26.0.1",
84
- "inline-snapshot>=0.32.2",
95
+ "pip>=26.1.2",
96
+ "inline-snapshot>=0.34.1",
85
97
  ]
@@ -0,0 +1,3 @@
1
+ from .patch import apply_patch
2
+
3
+ __all__ = ["apply_patch"]
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,270 @@
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
+ if hasattr(instance, name) and (
39
+ (val := getattr(instance, name)) is None or val is False
40
+ ):
41
+ delattr(instance, name)
42
+
43
+ if not hasattr(cls, name) or not isinstance(getattr(cls, name), cached_property):
44
+ prop = cached_property(func)
45
+ setattr(cls, name, prop)
46
+ prop.__set_name__(cls, name)
47
+
48
+
49
+ def _populate_api_route_state(
50
+ route: routing._APIRouteLike,
51
+ path: str,
52
+ endpoint: Callable[..., Any],
53
+ *,
54
+ response_model: Any = Default(None),
55
+ status_code: int | None = None,
56
+ tags: list[str | Enum] | None = None,
57
+ dependencies: Sequence[params.Depends] | None = None,
58
+ summary: str | None = None,
59
+ description: str | None = None,
60
+ response_description: str = "Successful Response",
61
+ responses: dict[int | str, dict[str, Any]] | None = None,
62
+ deprecated: bool | None = None,
63
+ name: str | None = None,
64
+ methods: set[str] | list[str] | None = None,
65
+ operation_id: str | None = None,
66
+ response_model_include: IncEx | None = None,
67
+ response_model_exclude: IncEx | None = None,
68
+ response_model_by_alias: bool = True,
69
+ response_model_exclude_unset: bool = False,
70
+ response_model_exclude_defaults: bool = False,
71
+ response_model_exclude_none: bool = False,
72
+ include_in_schema: bool = True,
73
+ response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse),
74
+ dependency_overrides_provider: Any | None = None,
75
+ callbacks: list[routing.BaseRoute] | None = None,
76
+ openapi_extra: dict[str, Any] | None = None,
77
+ generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default(
78
+ generate_unique_id
79
+ ),
80
+ strict_content_type: bool | DefaultPlaceholder = Default(True),
81
+ ) -> None:
82
+ route.path = path
83
+ route.endpoint = endpoint
84
+ route.stream_item_type = None
85
+ if isinstance(response_model, DefaultPlaceholder):
86
+ return_annotation = get_typed_return_annotation(endpoint)
87
+ if lenient_issubclass(return_annotation, Response):
88
+ response_model = None
89
+ else:
90
+ stream_item = get_stream_item_type(return_annotation)
91
+ if stream_item is not None:
92
+ # Extract item type for JSONL or SSE streaming when
93
+ # response_class is DefaultPlaceholder (JSONL) or
94
+ # EventSourceResponse (SSE).
95
+ # ServerSentEvent is excluded: it's a transport
96
+ # wrapper, not a data model, so it shouldn't feed
97
+ # into validation or OpenAPI schema generation.
98
+ if (
99
+ isinstance(response_class, DefaultPlaceholder)
100
+ or lenient_issubclass(response_class, EventSourceResponse)
101
+ ) and not lenient_issubclass(stream_item, ServerSentEvent):
102
+ route.stream_item_type = stream_item
103
+ response_model = None
104
+ else:
105
+ response_model = return_annotation
106
+ route.response_model = response_model
107
+ route.summary = summary
108
+ route.response_description = response_description
109
+ route.deprecated = deprecated
110
+ route.operation_id = operation_id
111
+ route.response_model_include = response_model_include
112
+ route.response_model_exclude = response_model_exclude
113
+ route.response_model_by_alias = response_model_by_alias
114
+ route.response_model_exclude_unset = response_model_exclude_unset
115
+ route.response_model_exclude_defaults = response_model_exclude_defaults
116
+ route.response_model_exclude_none = response_model_exclude_none
117
+ route.include_in_schema = include_in_schema
118
+ route.response_class = response_class
119
+ route.dependency_overrides_provider = dependency_overrides_provider
120
+ route.callbacks = callbacks
121
+ route.openapi_extra = openapi_extra
122
+ route.generate_unique_id_function = generate_unique_id_function
123
+ route.strict_content_type = strict_content_type
124
+ route.tags = tags or []
125
+ route.responses = responses or {}
126
+ route.name = routing.get_name(endpoint) if name is None else name
127
+ route.path_regex, route.path_format, route.param_convertors = routing.compile_path(
128
+ path
129
+ )
130
+ if methods is None:
131
+ methods = ["GET"]
132
+ route.methods = {method.upper() for method in methods}
133
+ if isinstance(generate_unique_id_function, DefaultPlaceholder):
134
+ current_generate_unique_id: Callable[[Any], str] = (
135
+ generate_unique_id_function.value
136
+ )
137
+ else:
138
+ current_generate_unique_id = generate_unique_id_function
139
+ route.unique_id = route.operation_id or current_generate_unique_id(route)
140
+ # normalize enums e.g. http.HTTPStatus
141
+ if isinstance(status_code, IntEnum):
142
+ status_code = int(status_code)
143
+ route.status_code = status_code
144
+ if route.response_model:
145
+ assert is_body_allowed_for_status_code(status_code), (
146
+ f"Status code {status_code} must not have a response body"
147
+ )
148
+
149
+ def _response_field(self):
150
+ if self.response_model:
151
+ response_name = "Response_" + self.unique_id
152
+ return create_model_field(
153
+ name=response_name,
154
+ type_=self.response_model,
155
+ mode="serialization",
156
+ )
157
+ else:
158
+ return None
159
+
160
+ _add_cache_attribute(route, "response_field", _response_field)
161
+
162
+ def _stream_item_field(self) -> ModelField | None:
163
+ if not self.stream_item_type:
164
+ return None
165
+ stream_item_name = "StreamItem_" + self.unique_id
166
+ return create_model_field(
167
+ name=stream_item_name,
168
+ type_=self.stream_item_type,
169
+ mode="serialization",
170
+ )
171
+
172
+ _add_cache_attribute(route, "stream_item_field", _stream_item_field)
173
+
174
+ route.dependencies = list(dependencies or [])
175
+ route.description = description or inspect.cleandoc(route.endpoint.__doc__ or "")
176
+ # if a "form feed" character (page break) is found in the description text,
177
+ # truncate description text to the content preceding the first "form feed"
178
+ route.description = route.description.split("\f")[0].strip()
179
+
180
+ def _response_fields(self) -> dict[Union[int, str], ModelField]:
181
+ response_fields: dict[Union[int, str], ModelField] = {}
182
+ for additional_status_code, response in self.responses.items():
183
+ assert isinstance(response, dict), "An additional response must be a dict"
184
+ model = response.get("model")
185
+ if model:
186
+ assert is_body_allowed_for_status_code(additional_status_code), (
187
+ f"Status code {additional_status_code} must not have a response body"
188
+ )
189
+ response_name = f"Response_{additional_status_code}_{self.unique_id}"
190
+ response_field = create_model_field(
191
+ name=response_name, type_=model, mode="serialization"
192
+ )
193
+ response_fields[additional_status_code] = response_field
194
+ return response_fields
195
+
196
+ _add_cache_attribute(route, "response_fields", _response_fields)
197
+
198
+ assert callable(endpoint), "An endpoint must be a callable"
199
+
200
+ def _get_dependant(self: routing._APIRouteLike) -> Dependant:
201
+ dependant = get_dependant(
202
+ path=self.path_format, call=self.endpoint, scope="function"
203
+ )
204
+
205
+ for depends in self.dependencies[::-1]:
206
+ dependant.dependencies.insert(
207
+ 0,
208
+ get_parameterless_sub_dependant(depends=depends, path=self.path_format),
209
+ )
210
+
211
+ return dependant
212
+
213
+ _add_cache_attribute(route, "dependant", _get_dependant)
214
+
215
+ def _get_flat_dependant(self) -> Dependant:
216
+ return get_flat_dependant(self.dependant)
217
+
218
+ _add_cache_attribute(route, "_flat_dependant", _get_flat_dependant)
219
+
220
+ def _embed_body_fields(self):
221
+ return _should_embed_body_fields(self._flat_dependant.body_params)
222
+
223
+ _add_cache_attribute(route, "_embed_body_fields", _embed_body_fields)
224
+
225
+ def _body_field(self):
226
+ return get_body_field(
227
+ flat_dependant=self._flat_dependant,
228
+ name=self.unique_id,
229
+ embed_body_fields=self._embed_body_fields,
230
+ )
231
+
232
+ _add_cache_attribute(route, "body_field", _body_field)
233
+
234
+ # Detect generator endpoints that should stream as JSONL or SSE
235
+ def _is_generator(self) -> bool:
236
+ return self.dependant.is_async_gen_callable or self.dependant.is_gen_callable
237
+
238
+ _add_cache_attribute(route, "is_generator", _is_generator)
239
+
240
+ def _is_sse_stream(self) -> bool:
241
+ return self.is_generator and lenient_issubclass(
242
+ self.response_class, EventSourceResponse
243
+ )
244
+
245
+ _add_cache_attribute(route, "is_sse_stream", _is_sse_stream)
246
+
247
+ def _is_json_stream(self) -> bool:
248
+ return self.is_generator and isinstance(self.response_class, DefaultPlaceholder)
249
+
250
+ _add_cache_attribute(route, "is_json_stream", _is_json_stream)
251
+
252
+
253
+ class DeferringAPIRoute(routing.APIRoute):
254
+ def __init__(
255
+ self, path: str, endpoint: Callable[..., Any], *args, **kwargs
256
+ ) -> None:
257
+ _populate_api_route_state(
258
+ cast(routing._APIRouteLike, self), path, endpoint, *args, **kwargs
259
+ )
260
+
261
+ @cached_property
262
+ def app(self):
263
+ return routing.request_response(self.get_route_handler())
264
+
265
+
266
+ class DeferringAPIRouter(routing.APIRouter):
267
+ def __init__(self, *args, **kwargs):
268
+ if "route_class" not in kwargs:
269
+ kwargs["route_class"] = DeferringAPIRoute
270
+ 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
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,10 +21,25 @@ 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(
31
+ # "fastapi.APIRouter", DeferringAPIRouter
32
+ # )
33
+
34
+
21
35
  @pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
22
- def test_basic(use_lib: bool, benchmark):
36
+ def test_basic(use_lib: bool, benchmark, monkeypatch):
23
37
  skip_test(use_lib=use_lib)
24
- create_code(50, use_lib=use_lib) # switch bool to compare
38
+ if use_lib:
39
+ apply_monkeypatch(monkeypatch)
40
+ create_code(
41
+ 50,
42
+ )
25
43
 
26
44
  generated_code = benchmark(load_code)
27
45
 
@@ -30,22 +48,27 @@ def test_basic(use_lib: bool, benchmark):
30
48
 
31
49
  app.include_router(router)
32
50
 
33
- assert type(router) is (DeferringAPIRouter if use_lib else APIRouter)
34
51
  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)
52
+ routes = list(routing.iter_route_contexts(app.routes))
53
+ assert len(routes) == 54
54
+ for route in routes:
55
+ if not route.path or route.path in ("/openapi.json"):
56
+ continue
40
57
  resp = client.get(route.path)
41
58
  assert resp.status_code == 200
42
59
 
43
60
 
44
61
  @pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
45
- def test_with_pydantic_model(use_lib: bool):
62
+ def test_with_pydantic_model(use_lib: bool, monkeypatch):
63
+
46
64
  skip_test(use_lib=use_lib)
65
+ if use_lib:
66
+ apply_monkeypatch(monkeypatch)
47
67
  app = FastAPI()
48
- router = DeferringAPIRouter() if use_lib else APIRouter()
68
+ router = routing.APIRouter()
69
+ assert isinstance(router, DeferringAPIRouter if use_lib else routing.APIRouter), (
70
+ type(router)
71
+ )
49
72
 
50
73
  class Login(BaseModel):
51
74
  username: str
@@ -59,6 +82,12 @@ def test_with_pydantic_model(use_lib: bool):
59
82
 
60
83
  return {"userdata": "[...]"}
61
84
 
85
+ assert isinstance(
86
+ router.routes[0], DeferringAPIRoute if use_lib else routing.APIRoute
87
+ ), (
88
+ router.routes,
89
+ type(router.routes[0]),
90
+ )
62
91
  app.include_router(router)
63
92
 
64
93
  client = TestClient(app)
@@ -68,8 +97,8 @@ def test_with_pydantic_model(use_lib: bool):
68
97
 
69
98
 
70
99
  def test_fastapi_openapi_schema(monkeypatch):
71
- monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
72
- monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
100
+ apply_monkeypatch(monkeypatch)
101
+
73
102
  import_via_file_path(
74
103
  "fastapi_clone.tests.test_additional_properties",
75
104
  "fastapi/tests/test_additional_properties.py",
@@ -92,8 +121,7 @@ def test_fastapi_openapi_schema(monkeypatch):
92
121
 
93
122
 
94
123
  def test_fastapi_sse(monkeypatch):
95
- monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
96
- monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
124
+ apply_monkeypatch(monkeypatch)
97
125
  import_via_file_path(
98
126
  "fastapi_clone.tests.test_sse",
99
127
  "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
- )