fastapi-deferred-init 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fastapi-deferred-init
3
- Version: 0.3.2
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.10
21
- Requires-Dist: fastapi>=0.140.7
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.140.7",
15
+ "fastapi>=0.162.0",
16
16
  ]
17
- requires-python = ">=3.10"
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.2"
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
- "pre-commit install",
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.pycln]
84
- all = true
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.15.20",
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.2.3",
94
- "pip>=26.1.2",
95
- "inline-snapshot>=0.34.2",
96
- "httpx2>=2.9.1",
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
  ]
@@ -1,4 +1,9 @@
1
- from .routing import _populate_api_route_state, DeferringAPIRoute, DeferringAPIRouter
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
 
@@ -1,36 +1,37 @@
1
- from fastapi.dependencies.models import Dependant
2
1
  import inspect
2
+ from collections.abc import Callable, Sequence
3
3
  from enum import Enum, IntEnum
4
4
  from functools import cached_property
5
- from typing import Any, Callable, Union, cast
6
- from collections.abc import Sequence
7
-
5
+ from typing import Any, cast
8
6
 
9
- from fastapi.sse import (
10
- EventSourceResponse,
11
- ServerSentEvent,
12
- )
13
- from fastapi.routing import _is_async_gen_callable, _is_gen_callable
14
- from fastapi import params, routing # type: ignore[attr-defined]
15
7
  from fastapi._compat import ModelField, lenient_issubclass
16
8
  from fastapi.datastructures import Default, DefaultPlaceholder
9
+ from fastapi.dependencies.models import Dependant
17
10
  from fastapi.dependencies.utils import (
18
- _should_embed_body_fields,
19
11
  _get_body_field,
12
+ _get_flat_body_params,
13
+ _should_embed_body_fields,
20
14
  get_dependant,
21
15
  get_parameterless_sub_dependant,
22
- get_typed_return_annotation,
23
16
  get_stream_item_type,
24
- _get_flat_body_params,
17
+ get_typed_return_annotation,
25
18
  )
26
19
  from fastapi.responses import JSONResponse, Response
20
+ from fastapi.routing import _is_async_gen_callable, _is_gen_callable
21
+ from fastapi.sse import (
22
+ EventSourceResponse,
23
+ ServerSentEvent,
24
+ )
27
25
  from fastapi.types import IncEx
28
26
  from fastapi.utils import (
29
27
  create_model_field,
30
28
  generate_unique_id,
29
+ get_value_or_default,
31
30
  is_body_allowed_for_status_code,
32
31
  )
33
32
 
33
+ from fastapi import params, routing # type: ignore[attr-defined]
34
+
34
35
 
35
36
  def _add_cache_attribute(
36
37
  instance: routing._APIRouteLike, name: str, func: Callable[[Any], Any]
@@ -54,7 +55,7 @@ def _populate_api_route_state(
54
55
  path: str,
55
56
  endpoint: Callable[..., Any],
56
57
  *,
57
- response_model: Any = Default(None),
58
+ response_model: Any = Default(None), # noqa: B008
58
59
  status_code: int | None = None,
59
60
  tags: list[str | Enum] | None = None,
60
61
  dependencies: Sequence[params.Depends] | None = None,
@@ -73,40 +74,20 @@ def _populate_api_route_state(
73
74
  response_model_exclude_defaults: bool = False,
74
75
  response_model_exclude_none: bool = False,
75
76
  include_in_schema: bool = True,
76
- response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse),
77
+ response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), # noqa: B008
77
78
  dependency_overrides_provider: Any | None = None,
78
79
  callbacks: list[routing.BaseRoute] | None = None,
79
80
  openapi_extra: dict[str, Any] | None = None,
80
- generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default(
81
+ generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default( # noqa: B008
81
82
  generate_unique_id
82
83
  ),
83
- strict_content_type: bool | DefaultPlaceholder = Default(True),
84
+ strict_content_type: bool | DefaultPlaceholder = Default(True), # noqa: B008
85
+ stream_item_type: Any | None = None,
84
86
  ) -> None:
85
87
  route.path = path
86
88
  route.endpoint = endpoint
87
- route.stream_item_type = None
88
- if isinstance(response_model, DefaultPlaceholder):
89
- return_annotation = get_typed_return_annotation(endpoint)
90
- if lenient_issubclass(return_annotation, Response):
91
- response_model = None
92
- else:
93
- stream_item = get_stream_item_type(return_annotation)
94
- if stream_item is not None:
95
- # Extract item type for JSONL or SSE streaming when
96
- # response_class is DefaultPlaceholder (JSONL) or
97
- # EventSourceResponse (SSE).
98
- # ServerSentEvent is excluded: it's a transport
99
- # wrapper, not a data model, so it shouldn't feed
100
- # into validation or OpenAPI schema generation.
101
- if (
102
- isinstance(response_class, DefaultPlaceholder)
103
- or lenient_issubclass(response_class, EventSourceResponse)
104
- ) and not lenient_issubclass(stream_item, ServerSentEvent):
105
- route.stream_item_type = stream_item
106
- response_model = None
107
- else:
108
- response_model = return_annotation
109
- route.response_model = response_model
89
+ route.stream_item_type = stream_item_type
90
+
110
91
  route.summary = summary
111
92
  route.response_description = response_description
112
93
  route.deprecated = deprecated
@@ -144,13 +125,18 @@ def _populate_api_route_state(
144
125
  if isinstance(status_code, IntEnum):
145
126
  status_code = int(status_code)
146
127
  route.status_code = status_code
147
- if route.response_model:
148
- assert is_body_allowed_for_status_code(status_code), (
149
- f"Status code {status_code} must not have a response body"
150
- )
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]
151
134
 
152
135
  def _response_field(self):
153
136
  if self.response_model:
137
+ assert is_body_allowed_for_status_code(self.status_code), (
138
+ f"Status code {self.status_code} must not have a response body"
139
+ )
154
140
  response_name = "Response_" + self.unique_id
155
141
  return create_model_field(
156
142
  name=response_name,
@@ -180,8 +166,8 @@ def _populate_api_route_state(
180
166
  # truncate description text to the content preceding the first "form feed"
181
167
  route.description = route.description.split("\f")[0].strip()
182
168
 
183
- def _response_fields(self) -> dict[Union[int, str], ModelField]:
184
- response_fields: dict[Union[int, str], ModelField] = {}
169
+ def _response_fields(self) -> dict[int | str, ModelField]:
170
+ response_fields: dict[int | str, ModelField] = {}
185
171
  for additional_status_code, response in self.responses.items():
186
172
  assert isinstance(response, dict), "An additional response must be a dict"
187
173
  model = response.get("model")
@@ -254,6 +240,50 @@ def _populate_api_route_state(
254
240
 
255
241
  _add_cache_attribute(route, "is_json_stream", _is_json_stream)
256
242
 
243
+ def _resolved_response_model_stream_item(self):
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)
248
+ if lenient_issubclass(return_annotation, Response):
249
+ resolved_model = None
250
+ else:
251
+ stream_item = get_stream_item_type(return_annotation)
252
+ if stream_item is not None and self.is_generator:
253
+ # Extract item type for JSONL or SSE streaming for
254
+ # generator endpoints when response_class is
255
+ # DefaultPlaceholder (JSONL) or EventSourceResponse (SSE).
256
+ # ServerSentEvent is excluded: it's a transport
257
+ # wrapper, not a data model, so it shouldn't feed
258
+ # into validation or OpenAPI schema generation.
259
+ if (
260
+ isinstance(self.response_class, DefaultPlaceholder)
261
+ or lenient_issubclass(self.response_class, EventSourceResponse)
262
+ ) and not lenient_issubclass(stream_item, ServerSentEvent):
263
+ resolved_item_type = stream_item
264
+ resolved_model = None
265
+ else:
266
+ resolved_model = return_annotation
267
+
268
+ return resolved_model, resolved_item_type
269
+
270
+ _add_cache_attribute(
271
+ route,
272
+ "_resolved_response_model_stream_item",
273
+ _resolved_response_model_stream_item,
274
+ )
275
+
276
+ def _response_model(self):
277
+
278
+ return self._resolved_response_model_stream_item[0]
279
+
280
+ _add_cache_attribute(route, "response_model", _response_model)
281
+
282
+ def _stream_item_type(self):
283
+ return self._resolved_response_model_stream_item[1]
284
+
285
+ _add_cache_attribute(route, "stream_item_type", _stream_item_type)
286
+
257
287
 
258
288
  class DeferringAPIRoute(routing.APIRoute):
259
289
  def __init__(
@@ -273,3 +303,93 @@ class DeferringAPIRouter(routing.APIRouter):
273
303
  if "route_class" not in kwargs:
274
304
  kwargs["route_class"] = DeferringAPIRoute
275
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,8 +1,8 @@
1
1
  from fastapi import APIRouter, Depends
2
2
 
3
- dependency0 = lambda: 1 # noqa: E731
3
+ dependency0 = lambda: 1
4
4
 
5
- dependency = lambda: 0 # noqa: E731
5
+ dependency = lambda: 0
6
6
 
7
7
 
8
8
  def dependency1(sub_dependant: int = Depends(dependency0)):
@@ -13,7 +13,7 @@ router1 = APIRouter(prefix="/prefix1")
13
13
 
14
14
 
15
15
  @router1.get("/get1")
16
- def get1(dependency=Depends(dependency)):
16
+ def get1(dependency=Depends(dependency)): # noqa: B008
17
17
  return {"1": 1}
18
18
 
19
19
 
@@ -21,7 +21,7 @@ router2 = APIRouter(prefix="/prefix2")
21
21
 
22
22
 
23
23
  @router2.get("/get2")
24
- def get2(dependency=Depends(dependency)):
24
+ def get2(dependency=Depends(dependency)): # noqa: B008
25
25
  return {"2": 2}
26
26
 
27
27
 
@@ -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
+ )
@@ -1,19 +1,17 @@
1
1
  import os
2
2
 
3
3
  import pytest
4
+ from fastapi.testclient import TestClient
4
5
  from pydantic import BaseModel
5
6
 
6
- from fastapi import FastAPI, APIRouter
7
- from fastapi import routing
8
- from fastapi.testclient import TestClient
7
+ from fastapi import APIRouter, FastAPI, routing
9
8
  from fastapi_deferred_init.routing import (
10
- _populate_api_route_state,
11
9
  DeferringAPIRoute,
12
10
  DeferringAPIRouter,
13
11
  )
14
12
 
15
13
  from .data.gen_code_ast import create_code
16
- from .helpers import import_via_file_path, load_code
14
+ from .helpers import apply_monkeypatch, import_via_file_path, load_code
17
15
 
18
16
 
19
17
  def skip_test(use_lib: bool):
@@ -21,15 +19,6 @@ def skip_test(use_lib: bool):
21
19
  pytest.skip()
22
20
 
23
21
 
24
- def apply_monkeypatch(monkeypatch):
25
- monkeypatch.setattr(
26
- "fastapi.routing._populate_api_route_state", _populate_api_route_state
27
- )
28
- monkeypatch.setattr("fastapi.routing.APIRoute", DeferringAPIRoute)
29
- monkeypatch.setattr("fastapi.routing.APIRouter", DeferringAPIRouter)
30
- monkeypatch.setattr("fastapi.APIRouter", DeferringAPIRouter)
31
-
32
-
33
22
  @pytest.mark.parametrize(["use_lib"], [(True,), (False,)])
34
23
  def test_basic(use_lib: bool, benchmark, monkeypatch):
35
24
  skip_test(use_lib=use_lib)
@@ -107,10 +96,10 @@ def test_fastapi_openapi_schema(monkeypatch):
107
96
  "fastapi/tests/test_openapi_examples.py",
108
97
  )
109
98
 
110
- from fastapi_clone.tests.test_additional_properties import ( # noqa # type: ignore
99
+ from fastapi_clone.tests.test_additional_properties import ( # type: ignore
111
100
  test_openapi_schema as fastapi_test_openapi_schema_additional,
112
101
  )
113
- from fastapi_clone.tests.test_openapi_examples import ( # noqa # type: ignore
102
+ from fastapi_clone.tests.test_openapi_examples import ( # type: ignore
114
103
  test_openapi_schema as fastapi_test_openapi_schema,
115
104
  )
116
105
 
@@ -125,9 +114,11 @@ def test_fastapi_sse(monkeypatch):
125
114
  "fastapi/tests/test_sse.py",
126
115
  )
127
116
 
117
+ from fastapi_clone.tests.test_sse import ( # ty: ignore[unresolved-import]
118
+ client_fixture as fastapi_client_fixture,
119
+ )
128
120
  from fastapi_clone.tests.test_sse import ( # type: ignore
129
121
  test_raw_data_sent_without_json_encoding as fastapi_test_raw_data_sent_without_json_encoding,
130
- client_fixture as fastapi_client_fixture,
131
122
  )
132
123
 
133
124
  for client in fastapi_client_fixture.__wrapped__():
@@ -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