fastapi-deferred-init 0.3.3__tar.gz → 0.3.4__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.
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/PKG-INFO +4 -4
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/pyproject.toml +30 -18
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/patch.py +7 -1
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/routing.py +105 -9
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/helpers.py +27 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/test_lib.py +2 -12
- fastapi_deferred_init-0.3.4/tests/test_realworld.py +314 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/LICENSE +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/README.md +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/__init__.py +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/py.typed +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/__init__.py +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/conftest.py +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/data/__init__.py +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/data/gen_code_ast.py +0 -0
- {fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/tests/data/template.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: fastapi-deferred-init
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Faster FastAPI start-up time for Projects with many nested routers
|
|
5
5
|
Keywords: fastapi,speed,router,startup,optimization
|
|
6
6
|
Author-Email: Jan Vollmer <jan@vllmr.dev>
|
|
@@ -9,16 +9,16 @@ Classifier: Environment :: Plugins
|
|
|
9
9
|
Classifier: Framework :: FastAPI
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
13
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Project-URL: Source, https://github.com/jvllmr/fastapi-deferred-init
|
|
19
19
|
Project-URL: Changelog, https://github.com/jvllmr/fastapi-deferred-init/releases
|
|
20
|
-
Requires-Python: >=3.
|
|
21
|
-
Requires-Dist: fastapi>=0.
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: fastapi>=0.162.0
|
|
22
22
|
Description-Content-Type: text/markdown
|
|
23
23
|
|
|
24
24
|
# fastapi-deferred-init
|
|
@@ -12,24 +12,24 @@ authors = [
|
|
|
12
12
|
{ name = "Jan Vollmer", email = "jan@vllmr.dev" },
|
|
13
13
|
]
|
|
14
14
|
dependencies = [
|
|
15
|
-
"fastapi>=0.
|
|
15
|
+
"fastapi>=0.162.0",
|
|
16
16
|
]
|
|
17
|
-
requires-python = ">=3.
|
|
17
|
+
requires-python = ">=3.11"
|
|
18
18
|
readme = "README.md"
|
|
19
19
|
classifiers = [
|
|
20
20
|
"Environment :: Plugins",
|
|
21
21
|
"Framework :: FastAPI",
|
|
22
22
|
"Intended Audience :: Developers",
|
|
23
23
|
"License :: OSI Approved :: MIT License",
|
|
24
|
-
"Programming Language :: Python :: 3.10",
|
|
25
24
|
"Programming Language :: Python :: 3.11",
|
|
26
25
|
"Programming Language :: Python :: 3.12",
|
|
27
26
|
"Programming Language :: Python :: 3.13",
|
|
28
27
|
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Programming Language :: Python :: 3.15",
|
|
29
29
|
"Typing :: Typed",
|
|
30
30
|
]
|
|
31
31
|
dynamic = []
|
|
32
|
-
version = "0.3.
|
|
32
|
+
version = "0.3.4"
|
|
33
33
|
|
|
34
34
|
[project.license]
|
|
35
35
|
text = "MIT"
|
|
@@ -45,9 +45,6 @@ requires = [
|
|
|
45
45
|
build-backend = "pdm.backend"
|
|
46
46
|
|
|
47
47
|
[tool.pdm]
|
|
48
|
-
ignore_package_warnings = [
|
|
49
|
-
"pre-commit",
|
|
50
|
-
]
|
|
51
48
|
plugins = [
|
|
52
49
|
"sync-pre-commit-lock",
|
|
53
50
|
]
|
|
@@ -69,7 +66,7 @@ FULL_TEST = "true"
|
|
|
69
66
|
|
|
70
67
|
[tool.pdm.scripts.post_install]
|
|
71
68
|
composite = [
|
|
72
|
-
"
|
|
69
|
+
"prek install",
|
|
73
70
|
"pdm install --plugins --frozen-lock",
|
|
74
71
|
"pip install ./fastapi",
|
|
75
72
|
]
|
|
@@ -80,18 +77,33 @@ source = "scm"
|
|
|
80
77
|
[tool.isort]
|
|
81
78
|
profile = "black"
|
|
82
79
|
|
|
83
|
-
[tool.
|
|
84
|
-
|
|
80
|
+
[tool.ruff]
|
|
81
|
+
target-version = "py311"
|
|
82
|
+
|
|
83
|
+
[tool.ruff.lint]
|
|
84
|
+
extend-select = [
|
|
85
|
+
"F",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.ty.environment]
|
|
89
|
+
python-version = "3.11"
|
|
90
|
+
python = ".venv"
|
|
91
|
+
|
|
92
|
+
[tool.ty.src]
|
|
93
|
+
include = [
|
|
94
|
+
"src",
|
|
95
|
+
"tests",
|
|
96
|
+
]
|
|
85
97
|
|
|
86
98
|
[dependency-groups]
|
|
87
99
|
dev = [
|
|
88
|
-
"pytest>=9.1.1",
|
|
89
|
-
"ruff>=0.
|
|
90
|
-
"pre-commit>=4.3.0",
|
|
91
|
-
"mypy>=2.1.0",
|
|
100
|
+
"pytest>=9.1.1,<9.2",
|
|
101
|
+
"ruff>=0.17.1",
|
|
92
102
|
"tuna>=0.5.15",
|
|
93
|
-
"pytest-benchmark>=5.
|
|
94
|
-
"pip>=26.1
|
|
95
|
-
"inline-snapshot>=0.
|
|
96
|
-
"httpx2>=2.
|
|
103
|
+
"pytest-benchmark>=5.3.0",
|
|
104
|
+
"pip>=26.2.1",
|
|
105
|
+
"inline-snapshot>=0.36.1",
|
|
106
|
+
"httpx2>=2.14.0",
|
|
107
|
+
"prek>=0.5.9",
|
|
108
|
+
"ty>=0.0.95",
|
|
97
109
|
]
|
{fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/patch.py
RENAMED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
from .routing import
|
|
1
|
+
from .routing import (
|
|
2
|
+
DeferringAPIRoute,
|
|
3
|
+
DeferringAPIRouter,
|
|
4
|
+
_populate_api_route_state,
|
|
5
|
+
apply_include_patch,
|
|
6
|
+
)
|
|
2
7
|
|
|
3
8
|
|
|
4
9
|
def apply_patch() -> None:
|
|
@@ -8,6 +13,7 @@ def apply_patch() -> None:
|
|
|
8
13
|
routing._populate_api_route_state = _populate_api_route_state # ty:ignore[invalid-assignment]
|
|
9
14
|
routing.APIRoute = DeferringAPIRoute # ty:ignore[invalid-assignment]
|
|
10
15
|
routing.APIRouter = DeferringAPIRouter # ty:ignore[invalid-assignment]
|
|
16
|
+
apply_include_patch(routing)
|
|
11
17
|
|
|
12
18
|
import fastapi
|
|
13
19
|
|
{fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/routing.py
RENAMED
|
@@ -26,6 +26,7 @@ from fastapi.types import IncEx
|
|
|
26
26
|
from fastapi.utils import (
|
|
27
27
|
create_model_field,
|
|
28
28
|
generate_unique_id,
|
|
29
|
+
get_value_or_default,
|
|
29
30
|
is_body_allowed_for_status_code,
|
|
30
31
|
)
|
|
31
32
|
|
|
@@ -125,10 +126,16 @@ def _populate_api_route_state(
|
|
|
125
126
|
status_code = int(status_code)
|
|
126
127
|
route.status_code = status_code
|
|
127
128
|
|
|
129
|
+
# Store the raw response_model / stream_item_type parameters so the
|
|
130
|
+
# deferred cached_properties can access them per-instance (not via
|
|
131
|
+
# closure, which would be shared across all instances of the class).
|
|
132
|
+
route._raw_response_model = response_model # ty: ignore[unresolved-attribute]
|
|
133
|
+
route._raw_stream_item_type = stream_item_type # ty: ignore[unresolved-attribute]
|
|
134
|
+
|
|
128
135
|
def _response_field(self):
|
|
129
136
|
if self.response_model:
|
|
130
|
-
assert is_body_allowed_for_status_code(status_code), (
|
|
131
|
-
f"Status code {status_code} must not have a response body"
|
|
137
|
+
assert is_body_allowed_for_status_code(self.status_code), (
|
|
138
|
+
f"Status code {self.status_code} must not have a response body"
|
|
132
139
|
)
|
|
133
140
|
response_name = "Response_" + self.unique_id
|
|
134
141
|
return create_model_field(
|
|
@@ -234,11 +241,10 @@ def _populate_api_route_state(
|
|
|
234
241
|
_add_cache_attribute(route, "is_json_stream", _is_json_stream)
|
|
235
242
|
|
|
236
243
|
def _resolved_response_model_stream_item(self):
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return_annotation = get_typed_return_annotation(endpoint)
|
|
244
|
+
resolved_model = self._raw_response_model
|
|
245
|
+
resolved_item_type = self._raw_stream_item_type
|
|
246
|
+
if isinstance(resolved_model, DefaultPlaceholder):
|
|
247
|
+
return_annotation = get_typed_return_annotation(self.endpoint)
|
|
242
248
|
if lenient_issubclass(return_annotation, Response):
|
|
243
249
|
resolved_model = None
|
|
244
250
|
else:
|
|
@@ -251,8 +257,8 @@ def _populate_api_route_state(
|
|
|
251
257
|
# wrapper, not a data model, so it shouldn't feed
|
|
252
258
|
# into validation or OpenAPI schema generation.
|
|
253
259
|
if (
|
|
254
|
-
isinstance(response_class, DefaultPlaceholder)
|
|
255
|
-
or lenient_issubclass(response_class, EventSourceResponse)
|
|
260
|
+
isinstance(self.response_class, DefaultPlaceholder)
|
|
261
|
+
or lenient_issubclass(self.response_class, EventSourceResponse)
|
|
256
262
|
) and not lenient_issubclass(stream_item, ServerSentEvent):
|
|
257
263
|
resolved_item_type = stream_item
|
|
258
264
|
resolved_model = None
|
|
@@ -297,3 +303,93 @@ class DeferringAPIRouter(routing.APIRouter):
|
|
|
297
303
|
if "route_class" not in kwargs:
|
|
298
304
|
kwargs["route_class"] = DeferringAPIRoute
|
|
299
305
|
super().__init__(*args, **kwargs)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _from_api_route_deferred(
|
|
309
|
+
cls: type, *, original_route: Any, include_context: Any
|
|
310
|
+
) -> Any:
|
|
311
|
+
"""Deferring replacement for fastapi>=0.162's `_EffectiveRouteContext.from_api_route`.
|
|
312
|
+
|
|
313
|
+
Upstream eagerly builds the ASGI app for every route of an included router
|
|
314
|
+
(`context.app = request_response(original_route.get_route_handler())`),
|
|
315
|
+
which forces the deferred dependant/body_field/response_model to be
|
|
316
|
+
computed at include time. Defer `app` to first access instead, like
|
|
317
|
+
DeferringAPIRoute does for direct routes.
|
|
318
|
+
"""
|
|
319
|
+
route = cast(routing._APIRouteLike, original_route)
|
|
320
|
+
context = cls(original_route=original_route)
|
|
321
|
+
_populate_api_route_state(
|
|
322
|
+
cast(routing._APIRouteLike, context),
|
|
323
|
+
include_context.path_for(original_route),
|
|
324
|
+
route.endpoint,
|
|
325
|
+
response_model=route.response_model,
|
|
326
|
+
status_code=route.status_code,
|
|
327
|
+
tags=[*include_context.tags, *route.tags],
|
|
328
|
+
dependencies=[*include_context.dependencies, *route.dependencies],
|
|
329
|
+
summary=route.summary,
|
|
330
|
+
description=route.description,
|
|
331
|
+
response_description=route.response_description,
|
|
332
|
+
responses={**include_context.responses, **route.responses},
|
|
333
|
+
deprecated=route.deprecated or include_context.deprecated,
|
|
334
|
+
methods=route.methods,
|
|
335
|
+
operation_id=route.operation_id,
|
|
336
|
+
response_model_include=route.response_model_include,
|
|
337
|
+
response_model_exclude=route.response_model_exclude,
|
|
338
|
+
response_model_by_alias=route.response_model_by_alias,
|
|
339
|
+
response_model_exclude_unset=route.response_model_exclude_unset,
|
|
340
|
+
response_model_exclude_defaults=route.response_model_exclude_defaults,
|
|
341
|
+
response_model_exclude_none=route.response_model_exclude_none,
|
|
342
|
+
include_in_schema=route.include_in_schema and include_context.include_in_schema,
|
|
343
|
+
response_class=get_value_or_default(
|
|
344
|
+
route.response_class,
|
|
345
|
+
include_context.included_router.default_response_class,
|
|
346
|
+
include_context.default_response_class,
|
|
347
|
+
),
|
|
348
|
+
name=route.name,
|
|
349
|
+
dependency_overrides_provider=include_context.dependency_overrides_provider,
|
|
350
|
+
callbacks=[*include_context.callbacks, *(route.callbacks or [])],
|
|
351
|
+
openapi_extra=route.openapi_extra,
|
|
352
|
+
generate_unique_id_function=get_value_or_default(
|
|
353
|
+
route.generate_unique_id_function,
|
|
354
|
+
include_context.included_router.generate_unique_id_function,
|
|
355
|
+
include_context.generate_unique_id_function,
|
|
356
|
+
),
|
|
357
|
+
strict_content_type=get_value_or_default(
|
|
358
|
+
route.strict_content_type,
|
|
359
|
+
include_context.included_router.strict_content_type,
|
|
360
|
+
include_context.strict_content_type,
|
|
361
|
+
),
|
|
362
|
+
stream_item_type=route.stream_item_type,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _app(self: Any) -> Any:
|
|
366
|
+
# Build the handler lazily with the effective context active, mirroring
|
|
367
|
+
# upstream's ContextVar usage so integrations wrapping dependant.call
|
|
368
|
+
# while constructing the handler still see the context.
|
|
369
|
+
context_var = getattr(routing, "_effective_route_context_var", None)
|
|
370
|
+
if context_var is not None:
|
|
371
|
+
token = context_var.set(self)
|
|
372
|
+
try:
|
|
373
|
+
handler = self.original_route.get_route_handler()
|
|
374
|
+
finally:
|
|
375
|
+
context_var.reset(token)
|
|
376
|
+
else:
|
|
377
|
+
handler = self.original_route.get_route_handler()
|
|
378
|
+
return routing.request_response(handler)
|
|
379
|
+
|
|
380
|
+
_add_cache_attribute(context, "app", _app)
|
|
381
|
+
return context
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def apply_include_patch(routing_module: Any) -> bool:
|
|
385
|
+
"""Patch fastapi>=0.162's include machinery to defer app construction.
|
|
386
|
+
|
|
387
|
+
Returns True when the patch was applied, i.e. the running fastapi version
|
|
388
|
+
has `_EffectiveRouteContext` (older versions re-run APIRoute.__init__ on
|
|
389
|
+
include, which the route-class patch already covers).
|
|
390
|
+
"""
|
|
391
|
+
effective_route_context = getattr(routing_module, "_EffectiveRouteContext", None)
|
|
392
|
+
if effective_route_context is None:
|
|
393
|
+
return False
|
|
394
|
+
effective_route_context.from_api_route = classmethod(_from_api_route_deferred)
|
|
395
|
+
return True
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import importlib
|
|
4
|
+
import importlib.util
|
|
4
5
|
import sys
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from cProfile import Profile
|
|
7
8
|
from pstats import SortKey, Stats
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from fastapi_deferred_init.routing import (
|
|
12
|
+
DeferringAPIRoute,
|
|
13
|
+
DeferringAPIRouter,
|
|
14
|
+
_from_api_route_deferred,
|
|
15
|
+
_populate_api_route_state,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
import pytest
|
|
8
20
|
|
|
9
21
|
|
|
10
22
|
@contextmanager
|
|
@@ -26,6 +38,21 @@ def load_code():
|
|
|
26
38
|
def import_via_file_path(module_name: str, file_path: str):
|
|
27
39
|
# https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
|
|
28
40
|
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
41
|
+
assert spec is not None and spec.loader is not None
|
|
29
42
|
module = importlib.util.module_from_spec(spec)
|
|
30
43
|
sys.modules[module_name] = module
|
|
31
44
|
spec.loader.exec_module(module)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def apply_monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
48
|
+
monkeypatch.setattr(
|
|
49
|
+
"fastapi.routing._populate_api_route_state", _populate_api_route_state
|
|
50
|
+
)
|
|
51
|
+
monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
|
|
52
|
+
monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
|
|
53
|
+
monkeypatch.setattr("fastapi.APIRouter", DeferringAPIRouter)
|
|
54
|
+
monkeypatch.setattr(
|
|
55
|
+
"fastapi.routing._EffectiveRouteContext.from_api_route",
|
|
56
|
+
classmethod(_from_api_route_deferred),
|
|
57
|
+
raising=False,
|
|
58
|
+
)
|
|
@@ -8,11 +8,10 @@ from fastapi import APIRouter, FastAPI, routing
|
|
|
8
8
|
from fastapi_deferred_init.routing import (
|
|
9
9
|
DeferringAPIRoute,
|
|
10
10
|
DeferringAPIRouter,
|
|
11
|
-
_populate_api_route_state,
|
|
12
11
|
)
|
|
13
12
|
|
|
14
13
|
from .data.gen_code_ast import create_code
|
|
15
|
-
from .helpers import import_via_file_path, load_code
|
|
14
|
+
from .helpers import apply_monkeypatch, import_via_file_path, load_code
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
def skip_test(use_lib: bool):
|
|
@@ -20,15 +19,6 @@ def skip_test(use_lib: bool):
|
|
|
20
19
|
pytest.skip()
|
|
21
20
|
|
|
22
21
|
|
|
23
|
-
def apply_monkeypatch(monkeypatch):
|
|
24
|
-
monkeypatch.setattr(
|
|
25
|
-
"fastapi.routing._populate_api_route_state", _populate_api_route_state
|
|
26
|
-
)
|
|
27
|
-
monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
|
|
28
|
-
monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
|
|
29
|
-
monkeypatch.setattr("fastapi.APIRouter", DeferringAPIRouter)
|
|
30
|
-
|
|
31
|
-
|
|
32
22
|
@pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
|
|
33
23
|
def test_basic(use_lib: bool, benchmark, monkeypatch):
|
|
34
24
|
skip_test(use_lib=use_lib)
|
|
@@ -124,7 +114,7 @@ def test_fastapi_sse(monkeypatch):
|
|
|
124
114
|
"fastapi/tests/test_sse.py",
|
|
125
115
|
)
|
|
126
116
|
|
|
127
|
-
from fastapi_clone.tests.test_sse import (
|
|
117
|
+
from fastapi_clone.tests.test_sse import ( # ty: ignore[unresolved-import]
|
|
128
118
|
client_fixture as fastapi_client_fixture,
|
|
129
119
|
)
|
|
130
120
|
from fastapi_clone.tests.test_sse import ( # type: ignore
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Real-world usage tests for fastapi-deferred-init.
|
|
2
|
+
|
|
3
|
+
These tests mimic common patterns found in production FastAPI apps
|
|
4
|
+
that go beyond what the existing tests cover.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import typing as t
|
|
8
|
+
|
|
9
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from fastapi import Depends, FastAPI, Header, HTTPException, Query, routing, status
|
|
14
|
+
|
|
15
|
+
from .helpers import apply_monkeypatch
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class User(BaseModel):
|
|
19
|
+
id: int
|
|
20
|
+
name: str
|
|
21
|
+
email: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Item(BaseModel):
|
|
25
|
+
id: int
|
|
26
|
+
title: str
|
|
27
|
+
owner_id: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _make_app():
|
|
31
|
+
"""Build a realistic app. Route creation order matters: router routes
|
|
32
|
+
are created before app-level routes because decorators run top-to-bottom."""
|
|
33
|
+
|
|
34
|
+
def get_current_user() -> str:
|
|
35
|
+
return "fake-token"
|
|
36
|
+
|
|
37
|
+
def get_db() -> str:
|
|
38
|
+
return "fake-db-conn"
|
|
39
|
+
|
|
40
|
+
# --- sub-router: items (created FIRST, so its closures get installed on the class) ---
|
|
41
|
+
items_router = routing.APIRouter(prefix="/items", tags=["items"])
|
|
42
|
+
|
|
43
|
+
@items_router.get("/{item_id}", response_model=Item)
|
|
44
|
+
def read_item(item_id: int, db: t.Annotated[str, Depends(get_db)]):
|
|
45
|
+
return Item(id=item_id, title=f"Item {item_id}", owner_id=1)
|
|
46
|
+
|
|
47
|
+
@items_router.post("", response_model=Item, status_code=status.HTTP_201_CREATED)
|
|
48
|
+
def create_item(item: Item, user: t.Annotated[str, Depends(get_current_user)]):
|
|
49
|
+
return item
|
|
50
|
+
|
|
51
|
+
# --- sub-router: users ---
|
|
52
|
+
users_router = routing.APIRouter(prefix="/users", tags=["users"])
|
|
53
|
+
|
|
54
|
+
@users_router.get("/{user_id}", response_model=User)
|
|
55
|
+
def read_user(user_id: int):
|
|
56
|
+
if user_id == 999:
|
|
57
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
58
|
+
return User(
|
|
59
|
+
id=user_id, name=f"User {user_id}", email=f"user{user_id}@example.com"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# --- v1 router ---
|
|
63
|
+
v1_router = routing.APIRouter(prefix="/v1")
|
|
64
|
+
v1_router.include_router(items_router)
|
|
65
|
+
v1_router.include_router(users_router, prefix="/u", tags=["v1-users"])
|
|
66
|
+
|
|
67
|
+
# --- app (created LAST) ---
|
|
68
|
+
app = FastAPI(title="Real World App")
|
|
69
|
+
|
|
70
|
+
@app.get("/", response_class=PlainTextResponse)
|
|
71
|
+
def root():
|
|
72
|
+
return "Hello World"
|
|
73
|
+
|
|
74
|
+
@app.get("/health")
|
|
75
|
+
def health():
|
|
76
|
+
return {"status": "ok"}
|
|
77
|
+
|
|
78
|
+
app.include_router(v1_router)
|
|
79
|
+
return app
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_nested_routers_with_response_model_and_dependencies(monkeypatch):
|
|
83
|
+
apply_monkeypatch(monkeypatch)
|
|
84
|
+
app = _make_app()
|
|
85
|
+
client = TestClient(app)
|
|
86
|
+
|
|
87
|
+
# Direct app routes
|
|
88
|
+
resp = client.get("/")
|
|
89
|
+
assert resp.status_code == 200, resp.text
|
|
90
|
+
assert resp.text == "Hello World"
|
|
91
|
+
|
|
92
|
+
resp = client.get("/health")
|
|
93
|
+
assert resp.status_code == 200
|
|
94
|
+
assert resp.json() == {"status": "ok"}
|
|
95
|
+
|
|
96
|
+
# Nested router routes
|
|
97
|
+
resp = client.get("/v1/items/42")
|
|
98
|
+
assert resp.status_code == 200, resp.text
|
|
99
|
+
assert resp.json() == {"id": 42, "title": "Item 42", "owner_id": 1}
|
|
100
|
+
|
|
101
|
+
resp = client.post("/v1/items", json={"id": 1, "title": "Test", "owner_id": 2})
|
|
102
|
+
assert resp.status_code == 201, resp.text
|
|
103
|
+
assert resp.json() == {"id": 1, "title": "Test", "owner_id": 2}
|
|
104
|
+
|
|
105
|
+
resp = client.get("/v1/u/users/5")
|
|
106
|
+
assert resp.status_code == 200, resp.text
|
|
107
|
+
assert resp.json() == {"id": 5, "name": "User 5", "email": "user5@example.com"}
|
|
108
|
+
|
|
109
|
+
resp = client.get("/v1/u/users/999")
|
|
110
|
+
assert resp.status_code == 404, resp.text
|
|
111
|
+
|
|
112
|
+
# OpenAPI schema should be generatable
|
|
113
|
+
resp = client.get("/openapi.json")
|
|
114
|
+
assert resp.status_code == 200, resp.text
|
|
115
|
+
schema = resp.json()
|
|
116
|
+
assert "/v1/items/{item_id}" in schema["paths"]
|
|
117
|
+
assert "/v1/u/users/{user_id}" in schema["paths"]
|
|
118
|
+
assert "/" in schema["paths"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_response_model_not_leaked_between_routes(monkeypatch):
|
|
122
|
+
"""Regression test: response_model from one route must not leak to another.
|
|
123
|
+
|
|
124
|
+
The deferred cached_property closures were installed once per class,
|
|
125
|
+
capturing the first route's parameters for all subsequent routes.
|
|
126
|
+
"""
|
|
127
|
+
apply_monkeypatch(monkeypatch)
|
|
128
|
+
|
|
129
|
+
# First route with response_model — its closures get installed on the class
|
|
130
|
+
router_a = routing.APIRouter()
|
|
131
|
+
|
|
132
|
+
@router_a.get("/a", response_model=Item)
|
|
133
|
+
def get_a():
|
|
134
|
+
return Item(id=1, title="A", owner_id=0)
|
|
135
|
+
|
|
136
|
+
# Second route WITHOUT response_model — should NOT inherit Item
|
|
137
|
+
router_b = routing.APIRouter()
|
|
138
|
+
|
|
139
|
+
@router_b.get("/b")
|
|
140
|
+
def get_b():
|
|
141
|
+
return {"plain": "dict"}
|
|
142
|
+
|
|
143
|
+
app = FastAPI()
|
|
144
|
+
app.include_router(router_a)
|
|
145
|
+
app.include_router(router_b)
|
|
146
|
+
|
|
147
|
+
# Check the original routes' response_model
|
|
148
|
+
for r in router_b.routes:
|
|
149
|
+
assert r.response_model is None, ( # ty: ignore[unresolved-attribute]
|
|
150
|
+
f"Route {r.path} incorrectly has response_model={r.response_model} " # ty: ignore[unresolved-attribute]
|
|
151
|
+
f"(leaked from another route)"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
client = TestClient(app)
|
|
155
|
+
resp = client.get("/b")
|
|
156
|
+
assert resp.status_code == 200, resp.text
|
|
157
|
+
assert resp.json() == {"plain": "dict"}
|
|
158
|
+
|
|
159
|
+
# Also verify /a still works
|
|
160
|
+
resp = client.get("/a")
|
|
161
|
+
assert resp.status_code == 200, resp.text
|
|
162
|
+
assert resp.json() == {"id": 1, "title": "A", "owner_id": 0}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_custom_response_class_not_leaked(monkeypatch):
|
|
166
|
+
"""Custom response_class on one route must not leak to others."""
|
|
167
|
+
apply_monkeypatch(monkeypatch)
|
|
168
|
+
|
|
169
|
+
router = routing.APIRouter()
|
|
170
|
+
|
|
171
|
+
@router.get("/html", response_class=PlainTextResponse)
|
|
172
|
+
def html():
|
|
173
|
+
return "<h1>hi</h1>"
|
|
174
|
+
|
|
175
|
+
@router.get("/json")
|
|
176
|
+
def json_route():
|
|
177
|
+
return {"ok": True}
|
|
178
|
+
|
|
179
|
+
app = FastAPI()
|
|
180
|
+
app.include_router(router)
|
|
181
|
+
|
|
182
|
+
client = TestClient(app)
|
|
183
|
+
resp = client.get("/html")
|
|
184
|
+
assert resp.status_code == 200
|
|
185
|
+
assert "text/plain" in resp.headers["content-type"]
|
|
186
|
+
|
|
187
|
+
resp = client.get("/json")
|
|
188
|
+
assert resp.status_code == 200
|
|
189
|
+
assert "application/json" in resp.headers["content-type"]
|
|
190
|
+
assert resp.json() == {"ok": True}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_dependency_overrides(monkeypatch):
|
|
194
|
+
apply_monkeypatch(monkeypatch)
|
|
195
|
+
|
|
196
|
+
def original_dep():
|
|
197
|
+
return "original"
|
|
198
|
+
|
|
199
|
+
def override_dep():
|
|
200
|
+
return "overridden"
|
|
201
|
+
|
|
202
|
+
router = routing.APIRouter()
|
|
203
|
+
|
|
204
|
+
@router.get("/dep")
|
|
205
|
+
def get_dep(val: t.Annotated[str, Depends(original_dep)]):
|
|
206
|
+
return {"val": val}
|
|
207
|
+
|
|
208
|
+
app = FastAPI()
|
|
209
|
+
app.include_router(router)
|
|
210
|
+
app.dependency_overrides[original_dep] = override_dep
|
|
211
|
+
|
|
212
|
+
client = TestClient(app)
|
|
213
|
+
resp = client.get("/dep")
|
|
214
|
+
assert resp.status_code == 200, resp.text
|
|
215
|
+
assert resp.json() == {"val": "overridden"}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_multiple_includes_same_router(monkeypatch):
|
|
219
|
+
apply_monkeypatch(monkeypatch)
|
|
220
|
+
|
|
221
|
+
router = routing.APIRouter()
|
|
222
|
+
|
|
223
|
+
@router.get("/ping")
|
|
224
|
+
def ping():
|
|
225
|
+
return {"pong": True}
|
|
226
|
+
|
|
227
|
+
app = FastAPI()
|
|
228
|
+
app.include_router(router, prefix="/api/v1")
|
|
229
|
+
app.include_router(router, prefix="/api/v2")
|
|
230
|
+
|
|
231
|
+
client = TestClient(app)
|
|
232
|
+
for prefix in ("/api/v1", "/api/v2"):
|
|
233
|
+
resp = client.get(f"{prefix}/ping")
|
|
234
|
+
assert resp.status_code == 200, f"{prefix}: {resp.text}"
|
|
235
|
+
assert resp.json() == {"pong": True}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_path_and_query_params(monkeypatch):
|
|
239
|
+
apply_monkeypatch(monkeypatch)
|
|
240
|
+
|
|
241
|
+
router = routing.APIRouter(prefix="/search")
|
|
242
|
+
|
|
243
|
+
@router.get("/{category}")
|
|
244
|
+
def search(
|
|
245
|
+
category: str,
|
|
246
|
+
q: str = Query(default=""),
|
|
247
|
+
limit: int = Query(default=10, le=100),
|
|
248
|
+
x_token: str = Header(default=""),
|
|
249
|
+
):
|
|
250
|
+
return {"category": category, "q": q, "limit": limit, "token": x_token}
|
|
251
|
+
|
|
252
|
+
app = FastAPI()
|
|
253
|
+
app.include_router(router)
|
|
254
|
+
|
|
255
|
+
client = TestClient(app)
|
|
256
|
+
resp = client.get("/search/books?q=fastapi&limit=5", headers={"X-Token": "abc"})
|
|
257
|
+
assert resp.status_code == 200, resp.text
|
|
258
|
+
assert resp.json() == {
|
|
259
|
+
"category": "books",
|
|
260
|
+
"q": "fastapi",
|
|
261
|
+
"limit": 5,
|
|
262
|
+
"token": "abc",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_head_returns_405_like_upstream(monkeypatch):
|
|
267
|
+
"""HEAD on a GET-only route returns 405 — same as unpatched FastAPI."""
|
|
268
|
+
apply_monkeypatch(monkeypatch)
|
|
269
|
+
|
|
270
|
+
router = routing.APIRouter()
|
|
271
|
+
|
|
272
|
+
@router.get("/auto")
|
|
273
|
+
def auto():
|
|
274
|
+
return {"ok": True}
|
|
275
|
+
|
|
276
|
+
app = FastAPI()
|
|
277
|
+
app.include_router(router)
|
|
278
|
+
|
|
279
|
+
client = TestClient(app)
|
|
280
|
+
resp = client.head("/auto")
|
|
281
|
+
assert resp.status_code == 405, resp.text
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_exception_handlers(monkeypatch):
|
|
285
|
+
apply_monkeypatch(monkeypatch)
|
|
286
|
+
|
|
287
|
+
router = routing.APIRouter()
|
|
288
|
+
|
|
289
|
+
class CustomError(Exception):
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
@router.get("/custom-error")
|
|
293
|
+
def raise_custom():
|
|
294
|
+
raise CustomError("something went wrong")
|
|
295
|
+
|
|
296
|
+
@router.get("/validation-error")
|
|
297
|
+
def cause_validation(q: int):
|
|
298
|
+
return {"q": q}
|
|
299
|
+
|
|
300
|
+
app = FastAPI()
|
|
301
|
+
|
|
302
|
+
@app.exception_handler(CustomError)
|
|
303
|
+
def custom_handler(request, exc):
|
|
304
|
+
return JSONResponse(status_code=418, content={"error": str(exc)})
|
|
305
|
+
|
|
306
|
+
app.include_router(router)
|
|
307
|
+
|
|
308
|
+
client = TestClient(app)
|
|
309
|
+
resp = client.get("/custom-error")
|
|
310
|
+
assert resp.status_code == 418, resp.text
|
|
311
|
+
assert resp.json() == {"error": "something went wrong"}
|
|
312
|
+
|
|
313
|
+
resp = client.get("/validation-error?q=notanint")
|
|
314
|
+
assert resp.status_code == 422, resp.text
|
|
File without changes
|
|
File without changes
|
{fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/__init__.py
RENAMED
|
File without changes
|
{fastapi_deferred_init-0.3.3 → fastapi_deferred_init-0.3.4}/src/fastapi_deferred_init/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|