fastapi-deferred-init 0.2.11__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.11
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.11"
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.0",
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.31.1",
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",
@@ -89,3 +118,19 @@ def test_fastapi_openapi_schema(monkeypatch):
89
118
 
90
119
  fastapi_test_openapi_schema()
91
120
  fastapi_test_openapi_schema_additional()
121
+
122
+
123
+ def test_fastapi_sse(monkeypatch):
124
+ apply_monkeypatch(monkeypatch)
125
+ import_via_file_path(
126
+ "fastapi_clone.tests.test_sse",
127
+ "fastapi/tests/test_sse.py",
128
+ )
129
+
130
+ from fastapi_clone.tests.test_sse import ( # type: ignore
131
+ test_raw_data_sent_without_json_encoding as fastapi_test_raw_data_sent_without_json_encoding,
132
+ client_fixture as fastapi_client_fixture,
133
+ )
134
+
135
+ for client in fastapi_client_fixture.__wrapped__():
136
+ fastapi_test_raw_data_sent_without_json_encoding(client)
@@ -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,238 +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
-
19
- from fastapi import params, routing # type:ignore
20
- from fastapi._compat import ModelField, lenient_issubclass
21
- from fastapi.datastructures import Default, DefaultPlaceholder
22
- from fastapi.dependencies.utils import (
23
- _should_embed_body_fields,
24
- get_body_field,
25
- get_dependant,
26
- get_flat_dependant,
27
- get_parameterless_sub_dependant,
28
- get_typed_return_annotation,
29
- )
30
- from fastapi.responses import JSONResponse, Response
31
- from fastapi.types import IncEx
32
- from fastapi.utils import (
33
- create_model_field,
34
- generate_unique_id,
35
- is_body_allowed_for_status_code,
36
- )
37
-
38
-
39
- class DeferringAPIRoute(routing.APIRoute):
40
- _getattr: ClassVar[Callable[[Any, str], Any]]
41
-
42
- def __init__(
43
- self,
44
- path: str,
45
- endpoint: Callable[..., Any],
46
- *,
47
- response_model: Any = Default(None),
48
- status_code: Optional[int] = None,
49
- tags: Optional[list[Union[str, Enum]]] = None,
50
- dependencies: Optional[Sequence[params.Depends]] = None,
51
- summary: Optional[str] = None,
52
- description: Optional[str] = None,
53
- response_description: str = "Successful Response",
54
- responses: Optional[dict[Union[int, str], dict[str, Any]]] = None,
55
- deprecated: Optional[bool] = None,
56
- name: Optional[str] = None,
57
- methods: Optional[Union[set[str], list[str]]] = None,
58
- operation_id: Optional[str] = None,
59
- response_model_include: Optional[IncEx] = None,
60
- response_model_exclude: Optional[IncEx] = None,
61
- response_model_by_alias: bool = True,
62
- response_model_exclude_unset: bool = False,
63
- response_model_exclude_defaults: bool = False,
64
- response_model_exclude_none: bool = False,
65
- include_in_schema: bool = True,
66
- response_class: Union[type[Response], DefaultPlaceholder] = Default(
67
- JSONResponse
68
- ),
69
- dependency_overrides_provider: Optional[Any] = None,
70
- callbacks: Optional[list[BaseRoute]] = None,
71
- openapi_extra: Optional[dict[str, Any]] = None,
72
- generate_unique_id_function: Union[
73
- Callable[["routing.APIRoute"], str], DefaultPlaceholder
74
- ] = Default(generate_unique_id),
75
- strict_content_type: bool = Default(True),
76
- ) -> None:
77
- self.path = path
78
- self.endpoint = endpoint
79
- if isinstance(response_model, DefaultPlaceholder):
80
- return_annotation = get_typed_return_annotation(endpoint)
81
- if lenient_issubclass(return_annotation, Response):
82
- response_model = None
83
- else:
84
- response_model = return_annotation
85
- self.response_model = response_model
86
- self.summary = summary
87
- self.response_description = response_description
88
- self.deprecated = deprecated
89
- self.operation_id = operation_id
90
- self.response_model_include = response_model_include
91
- self.response_model_exclude = response_model_exclude
92
- self.response_model_by_alias = response_model_by_alias
93
- self.response_model_exclude_unset = response_model_exclude_unset
94
- self.response_model_exclude_defaults = response_model_exclude_defaults
95
- self.response_model_exclude_none = response_model_exclude_none
96
- self.include_in_schema = include_in_schema
97
- self.response_class = response_class
98
- self.dependency_overrides_provider = dependency_overrides_provider
99
- self.callbacks = callbacks
100
- self.openapi_extra = openapi_extra
101
- self.generate_unique_id_function = generate_unique_id_function
102
- self.strict_content_type = strict_content_type
103
- self.tags = tags or []
104
- self.responses = responses or {}
105
- self.name = get_name(endpoint) if name is None else name
106
- self.path_regex, self.path_format, self.param_convertors = compile_path(path)
107
- if methods is None:
108
- methods = ["GET"]
109
- self.methods: set[str] = {method.upper() for method in methods}
110
- if isinstance(generate_unique_id_function, DefaultPlaceholder):
111
- current_generate_unique_id: Callable[[routing.APIRoute], str] = (
112
- generate_unique_id_function.value
113
- )
114
- else:
115
- current_generate_unique_id = generate_unique_id_function
116
- self.unique_id = self.operation_id or current_generate_unique_id(self)
117
- # normalize enums e.g. http.HTTPStatus
118
- if isinstance(status_code, IntEnum):
119
- status_code = int(status_code)
120
- self.status_code = status_code
121
- if self.response_model:
122
- assert is_body_allowed_for_status_code(status_code), (
123
- f"Status code {status_code} must not have a response body"
124
- )
125
- self.dependencies = list(dependencies or [])
126
- self.description = description or inspect.cleandoc(self.endpoint.__doc__ or "")
127
- self.description = self.description.split("\f")[0].strip()
128
-
129
- assert callable(endpoint), "An endpoint must be a callable"
130
-
131
- @cached_property
132
- def dependant(self):
133
- dependant = get_dependant(
134
- path=self.path_format, call=self.endpoint, scope="function"
135
- )
136
-
137
- for depends in self.dependencies[::-1]:
138
- dependant.dependencies.insert(
139
- 0,
140
- get_parameterless_sub_dependant(depends=depends, path=self.path_format),
141
- )
142
- return dependant
143
-
144
- @cached_property
145
- def _flat_dependant(self):
146
- return get_flat_dependant(self.dependant)
147
-
148
- @cached_property
149
- def _embed_body_fields(self):
150
- return _should_embed_body_fields(self._flat_dependant.body_params)
151
-
152
- @cached_property
153
- def response_field(self):
154
- if self.response_model:
155
- response_name = "Response_" + self.unique_id
156
- return create_model_field(
157
- name=response_name,
158
- type_=self.response_model,
159
- mode="serialization",
160
- )
161
- else:
162
- return None
163
-
164
- @cached_property
165
- def response_fields(self) -> dict[Union[int, str], ModelField]:
166
- response_fields: dict[Union[int, str], ModelField] = {}
167
- for additional_status_code, response in self.responses.items():
168
- assert isinstance(response, dict), "An additional response must be a dict"
169
- model = response.get("model")
170
- if model:
171
- assert is_body_allowed_for_status_code(additional_status_code), (
172
- f"Status code {additional_status_code} must not have a response body"
173
- )
174
- response_name = f"Response_{additional_status_code}_{self.unique_id}"
175
- response_field = create_model_field(
176
- name=response_name, type_=model, mode="serialization"
177
- )
178
- response_fields[additional_status_code] = response_field
179
- return response_fields
180
-
181
- @cached_property
182
- def body_field(self):
183
- return get_body_field(
184
- flat_dependant=self._flat_dependant,
185
- name=self.unique_id,
186
- embed_body_fields=self._embed_body_fields,
187
- )
188
-
189
- @cached_property
190
- def app(self):
191
- return routing.request_response(self.get_route_handler())
192
-
193
-
194
- class DeferringAPIRouter(routing.APIRouter):
195
- def __init__(
196
- self,
197
- *,
198
- prefix: str = "",
199
- tags: Optional[list[Union[str, Enum]]] = None,
200
- dependencies: Optional[Sequence[params.Depends]] = None,
201
- default_response_class: type[Response] = Default(JSONResponse),
202
- responses: Optional[dict[Union[int, str], dict[str, Any]]] = None,
203
- callbacks: Optional[list[BaseRoute]] = None,
204
- routes: Optional[list[BaseRoute]] = None,
205
- redirect_slashes: bool = True,
206
- default: Optional[ASGIApp] = None,
207
- dependency_overrides_provider: Optional[Any] = None,
208
- route_class: type[routing.APIRoute] = DeferringAPIRoute,
209
- on_startup: Optional[Sequence[Callable[[], Any]]] = None,
210
- on_shutdown: Optional[Sequence[Callable[[], Any]]] = None,
211
- lifespan: Optional[Lifespan[Any]] = None,
212
- deprecated: Optional[bool] = None,
213
- include_in_schema: bool = True,
214
- generate_unique_id_function: Callable[[routing.APIRoute], str] = Default(
215
- generate_unique_id
216
- ),
217
- strict_content_type: bool = Default(True),
218
- ) -> None:
219
- super().__init__(
220
- prefix=prefix,
221
- tags=tags,
222
- dependencies=dependencies,
223
- default_response_class=default_response_class,
224
- responses=responses,
225
- callbacks=callbacks,
226
- routes=routes,
227
- redirect_slashes=redirect_slashes,
228
- default=default,
229
- dependency_overrides_provider=dependency_overrides_provider,
230
- route_class=route_class,
231
- on_startup=on_startup,
232
- on_shutdown=on_shutdown,
233
- lifespan=lifespan,
234
- deprecated=deprecated,
235
- include_in_schema=include_in_schema,
236
- generate_unique_id_function=generate_unique_id_function,
237
- strict_content_type=strict_content_type,
238
- )