fastapi-injected 0.3.2__tar.gz → 0.3.6__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.
Files changed (71) hide show
  1. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/PKG-INFO +72 -6
  2. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/README.md +70 -4
  3. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/__init__.py +4 -1
  4. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_bind.py +10 -7
  5. fastapi_injected-0.3.6/fastapi_injected/_body.py +108 -0
  6. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_cache.py +17 -5
  7. fastapi_injected-0.3.6/fastapi_injected/_calls.py +192 -0
  8. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_deps_tp.py +9 -1
  9. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_fastapi_lifecycle.py +24 -7
  10. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_given.py +3 -2
  11. fastapi_injected-0.3.6/fastapi_injected/deps.py +227 -0
  12. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/overrides.py +2 -2
  13. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/resolve.py +7 -11
  14. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/scope.py +134 -17
  15. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/sign.py +18 -4
  16. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/types.py +4 -1
  17. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/pyproject.toml +3 -2
  18. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/integration.py +3 -2
  19. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/scope.py +17 -4
  20. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_bind.py +37 -3
  21. fastapi_injected-0.3.6/tests/test_body.py +522 -0
  22. fastapi_injected-0.3.6/tests/test_body_reads.py +263 -0
  23. fastapi_injected-0.3.6/tests/test_calls.py +117 -0
  24. fastapi_injected-0.3.6/tests/test_dependant_cache.py +249 -0
  25. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_fastapi.py +168 -1
  26. fastapi_injected-0.3.6/tests/test_request_params.py +172 -0
  27. fastapi_injected-0.3.6/uv.lock +819 -0
  28. fastapi_injected-0.3.2/fastapi_injected/deps.py +0 -156
  29. fastapi_injected-0.3.2/tests/test_dependant_cache.py +0 -92
  30. fastapi_injected-0.3.2/uv.lock +0 -776
  31. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.github/dependabot.yml +0 -0
  32. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.github/workflows/automerge.yml +0 -0
  33. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.github/workflows/lint.yml +0 -0
  34. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.github/workflows/publish.yml +0 -0
  35. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.github/workflows/test.yml +0 -0
  36. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.gitignore +0 -0
  37. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/.pre-commit-config.yaml +0 -0
  38. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/LICENSE +0 -0
  39. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_dataclass.py +0 -0
  40. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_injected.py +0 -0
  41. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_overrides.py +0 -0
  42. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/_rlock.py +0 -0
  43. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/fastapi_injected/inject.py +0 -0
  44. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/__init__.py +0 -0
  45. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/__init__.py +0 -0
  46. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/annotations.py +0 -0
  47. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/arg.py +0 -0
  48. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/bind.py +0 -0
  49. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/dataclass.py +0 -0
  50. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/deps.py +0 -0
  51. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/errors.py +0 -0
  52. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/given.py +0 -0
  53. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/inject.py +0 -0
  54. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/injected.py +0 -0
  55. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/markers.py +0 -0
  56. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/overrides.py +0 -0
  57. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/_typing/resolve.py +0 -0
  58. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/deps.py +0 -0
  59. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/ext/__init__.py +0 -0
  60. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/ext/test_pydantic_ai.py +0 -0
  61. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_cache.py +0 -0
  62. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_concurrency.py +0 -0
  63. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_dataclass.py +0 -0
  64. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_errors.py +0 -0
  65. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_given.py +0 -0
  66. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_inject.py +0 -0
  67. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_injected.py +0 -0
  68. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_overrides.py +0 -0
  69. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_resolve.py +0 -0
  70. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_rlock.py +0 -0
  71. {fastapi_injected-0.3.2 → fastapi_injected-0.3.6}/tests/test_scope.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastapi-injected
3
- Version: 0.3.2
3
+ Version: 0.3.6
4
4
  Summary: Yet another library to reuse fastapi dependency injection
5
5
  Project-URL: Repository, https://github.com/uriyyo/fastapi-injected
6
6
  Author-email: Yurii Karabas <1998uriyyo@gmail.com>
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Requires-Python: >=3.12
17
- Requires-Dist: fastapi>=0.139.2
17
+ Requires-Dist: fastapi>=0.141.0
18
18
  Requires-Dist: typing-extensions>=4.16.0
19
19
  Requires-Dist: typing-inspection>=0.4.4
20
20
  Description-Content-Type: text/markdown
@@ -164,7 +164,25 @@ async with push_inject_scope(app=app):
164
164
 
165
165
  Without it, reading `request.app` raises a `KeyError` naming what is missing rather than a bare `'app'`.
166
166
 
167
- Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. `clear_dependant_cache()` drops it, for long-lived processes and test suites that want the memory back.
167
+ When a dependency reads more of the request than that — a header, a cookie, a query or path parameter, the body — describe the request with `synthetic_request` and hand it to the scope:
168
+
169
+ ```python
170
+ from fastapi_injected import push_inject_scope, synthetic_request
171
+
172
+ request = synthetic_request(
173
+ headers={"x-trace": "job-42"},
174
+ cookies={"sid": "abc"},
175
+ query={"page": "2"},
176
+ path_params={"item_id": 7},
177
+ body={"name": "readme"}, # sent as JSON; bytes and str are sent as they are
178
+ app=app,
179
+ )
180
+
181
+ async with push_inject_scope(request=request):
182
+ await handler() # `Header()`, `Cookie()`, `Query()`, `Path()` and `Body()` resolve from it
183
+ ```
184
+
185
+ Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. Only dependencies written in source code are cached this way: functions and classes, and annotations built from them. One made at runtime — `Given(...)`, a `MakeInjected` or `bind_deps` result, an object that brings its own `Depends` — is analysed again on every resolve, and FastAPI never gets to keep it: what it carries is released with the resolve instead of living as long as the process. `clear_dependant_cache()` drops the cache, for long-lived processes and test suites that want the memory back.
168
186
 
169
187
  ### Overriding dependencies
170
188
 
@@ -227,7 +245,7 @@ async with push_inject_scope({Session: fake}) as scope:
227
245
 
228
246
  ### Dependencies that are objects
229
247
 
230
- Dependants are cached by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
248
+ Resolved values are cached in their scope by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
231
249
 
232
250
  `MakeDataclass` is a base class that makes its subclasses dataclasses with a hash that always answers: by fields when they can be hashed, by identity when they cannot.
233
251
 
@@ -351,6 +369,54 @@ async def route(service: Dep[Service]) -> str:
351
369
 
352
370
  Anything called from the handler — including `@inject`-ed helpers — resolves against the request's cache, so a per-request dependency like a DB session stays a single instance for the whole request.
353
371
 
372
+ WebSocket routes work the same way: the scope is bound to the `WebSocket` instead of a `Request`, and dependencies that ask for one get it. A websocket handed to a dependency shares its handshake with the one the route received, so it can be talked through after the route has called `accept()`:
373
+
374
+ ```python
375
+ async def notify(websocket: WebSocket) -> None:
376
+ await websocket.send_json({"ready": True})
377
+
378
+
379
+ @app.websocket("/ws")
380
+ async def ws_route(websocket: WebSocket, service: Dep[Service]) -> None:
381
+ await websocket.accept()
382
+ await resolve(notify) # sends through the connection the route accepted
383
+ ```
384
+
385
+ Dependencies that take request parameters — `Header()`, `Cookie()`, `Query()`, `Path()`, and the body as `Body()`, `Form()`, `File()` or a pydantic model — resolve from the request the scope is bound to, exactly as they would in the route:
386
+
387
+ ```python
388
+ class Item(BaseModel):
389
+ name: str
390
+
391
+
392
+ async def get_item(item: Item) -> Item:
393
+ return item
394
+
395
+
396
+ @app.post("/items")
397
+ async def create(request: Request) -> str:
398
+ item = await resolve(get_item) # the request body, parsed as FastAPI would
399
+ ...
400
+ ```
401
+
402
+ A request parameter is a dependency of its own as well - resolve a marker, or an annotation carrying one, without writing a function around it. On its own it has no parameter name, so the marker says what to read; a marker without an alias raises `UnnamedParamError`:
403
+
404
+ ```python
405
+ api_key = await resolve(Header(alias="x-api-key")) # the raw header, as `Any`
406
+ page = await resolve(Annotated[int, Query(alias="page")]) # validated as `int`
407
+ item_id = await resolve(Annotated[int, Path(alias="item_id")])
408
+ item = await resolve(Annotated[Item, Body()]) # the whole body needs no name
409
+
410
+
411
+ @inject
412
+ async def handler(*, api_key: Annotated[str, Header()] = Injected) -> str:
413
+ return api_key # a parameter has a name - `x-api-key`, as FastAPI derives it
414
+ ```
415
+
416
+ The body is read only when the resolved dependency declares one, and it is read once per request, shared with the route and every scope nested in it. When the route itself declares a body, dependencies see it in the shape the route gave it: a route with several body parameters embeds them under their names, and a dependency reads its own from there.
417
+
418
+ `push_inject_scope(request=...)` accepts either a `Request` or a `WebSocket`, and so does everything that reads `InjectScope.request` — the union is `fastapi_injected.types.BoundConnection`.
419
+
354
420
  ## What is public
355
421
 
356
422
  Everything the package supports is importable from `fastapi_injected` itself, and that is the surface a release keeps:
@@ -359,10 +425,10 @@ Everything the package supports is importable from `fastapi_injected` itself, an
359
425
  | --- | --- |
360
426
  | Markers | `Dep`, `DepFactory`, `DepOf`, `Arg`, `Given`, `Injected` |
361
427
  | Resolving | `inject`, `resolve`, `bind_deps`, `signature_with_deps`, `remap_dep_args`, `clear_dependant_cache` |
362
- | Scopes and overrides | `InjectScope`, `push_inject_scope`, `inside_inject_scope`, `push_overrides`, `Overrides`, `OverridesProvider`, `ValueOverride`, `FactoryOverride` |
428
+ | Scopes and overrides | `InjectScope`, `push_inject_scope`, `inside_inject_scope`, `synthetic_request`, `push_overrides`, `Overrides`, `OverridesProvider`, `ValueOverride`, `FactoryOverride` |
363
429
  | Building on top | `MakeDataclass`, `MakeInjected`, `HasDependsHook`, `ArgMarker`, `is_arg`, `is_dep`, `unwrap_dep_tp`, `unwrap_dep_dependency` |
364
430
  | FastAPI integration | `add_injected_scope`, `init_inject_scope` |
365
- | Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError` |
431
+ | Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError`, `UnnamedParamError` |
366
432
 
367
433
  `fastapi_injected.types` holds the typing vocabulary the signatures are written in — `DepReturn`, `DepShape`, `DepDecl`, `AsyncFunc`, `Coro` and friends — and is public too. Anything else, including every module whose name starts with an underscore, is machinery that can change in a patch release.
368
434
 
@@ -143,7 +143,25 @@ async with push_inject_scope(app=app):
143
143
 
144
144
  Without it, reading `request.app` raises a `KeyError` naming what is missing rather than a bare `'app'`.
145
145
 
146
- Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. `clear_dependant_cache()` drops it, for long-lived processes and test suites that want the memory back.
146
+ When a dependency reads more of the request than that — a header, a cookie, a query or path parameter, the body — describe the request with `synthetic_request` and hand it to the scope:
147
+
148
+ ```python
149
+ from fastapi_injected import push_inject_scope, synthetic_request
150
+
151
+ request = synthetic_request(
152
+ headers={"x-trace": "job-42"},
153
+ cookies={"sid": "abc"},
154
+ query={"page": "2"},
155
+ path_params={"item_id": 7},
156
+ body={"name": "readme"}, # sent as JSON; bytes and str are sent as they are
157
+ app=app,
158
+ )
159
+
160
+ async with push_inject_scope(request=request):
161
+ await handler() # `Header()`, `Cookie()`, `Query()`, `Path()` and `Body()` resolve from it
162
+ ```
163
+
164
+ Analysing a dependency is the expensive part of resolving one, so the result is cached — keyed by the dependency itself, not by whatever object carried it, so nothing that only passed through is kept alive. Only dependencies written in source code are cached this way: functions and classes, and annotations built from them. One made at runtime — `Given(...)`, a `MakeInjected` or `bind_deps` result, an object that brings its own `Depends` — is analysed again on every resolve, and FastAPI never gets to keep it: what it carries is released with the resolve instead of living as long as the process. `clear_dependant_cache()` drops the cache, for long-lived processes and test suites that want the memory back.
147
165
 
148
166
  ### Overriding dependencies
149
167
 
@@ -206,7 +224,7 @@ async with push_inject_scope({Session: fake}) as scope:
206
224
 
207
225
  ### Dependencies that are objects
208
226
 
209
- Dependants are cached by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
227
+ Resolved values are cached in their scope by the callable that resolves them, so a dependency that is an object — a class holding configuration, a parametrized resolver — has to be hashable to get there. A plain dataclass is not, and a frozen one still refuses as soon as it holds a list or a dict.
210
228
 
211
229
  `MakeDataclass` is a base class that makes its subclasses dataclasses with a hash that always answers: by fields when they can be hashed, by identity when they cannot.
212
230
 
@@ -330,6 +348,54 @@ async def route(service: Dep[Service]) -> str:
330
348
 
331
349
  Anything called from the handler — including `@inject`-ed helpers — resolves against the request's cache, so a per-request dependency like a DB session stays a single instance for the whole request.
332
350
 
351
+ WebSocket routes work the same way: the scope is bound to the `WebSocket` instead of a `Request`, and dependencies that ask for one get it. A websocket handed to a dependency shares its handshake with the one the route received, so it can be talked through after the route has called `accept()`:
352
+
353
+ ```python
354
+ async def notify(websocket: WebSocket) -> None:
355
+ await websocket.send_json({"ready": True})
356
+
357
+
358
+ @app.websocket("/ws")
359
+ async def ws_route(websocket: WebSocket, service: Dep[Service]) -> None:
360
+ await websocket.accept()
361
+ await resolve(notify) # sends through the connection the route accepted
362
+ ```
363
+
364
+ Dependencies that take request parameters — `Header()`, `Cookie()`, `Query()`, `Path()`, and the body as `Body()`, `Form()`, `File()` or a pydantic model — resolve from the request the scope is bound to, exactly as they would in the route:
365
+
366
+ ```python
367
+ class Item(BaseModel):
368
+ name: str
369
+
370
+
371
+ async def get_item(item: Item) -> Item:
372
+ return item
373
+
374
+
375
+ @app.post("/items")
376
+ async def create(request: Request) -> str:
377
+ item = await resolve(get_item) # the request body, parsed as FastAPI would
378
+ ...
379
+ ```
380
+
381
+ A request parameter is a dependency of its own as well - resolve a marker, or an annotation carrying one, without writing a function around it. On its own it has no parameter name, so the marker says what to read; a marker without an alias raises `UnnamedParamError`:
382
+
383
+ ```python
384
+ api_key = await resolve(Header(alias="x-api-key")) # the raw header, as `Any`
385
+ page = await resolve(Annotated[int, Query(alias="page")]) # validated as `int`
386
+ item_id = await resolve(Annotated[int, Path(alias="item_id")])
387
+ item = await resolve(Annotated[Item, Body()]) # the whole body needs no name
388
+
389
+
390
+ @inject
391
+ async def handler(*, api_key: Annotated[str, Header()] = Injected) -> str:
392
+ return api_key # a parameter has a name - `x-api-key`, as FastAPI derives it
393
+ ```
394
+
395
+ The body is read only when the resolved dependency declares one, and it is read once per request, shared with the route and every scope nested in it. When the route itself declares a body, dependencies see it in the shape the route gave it: a route with several body parameters embeds them under their names, and a dependency reads its own from there.
396
+
397
+ `push_inject_scope(request=...)` accepts either a `Request` or a `WebSocket`, and so does everything that reads `InjectScope.request` — the union is `fastapi_injected.types.BoundConnection`.
398
+
333
399
  ## What is public
334
400
 
335
401
  Everything the package supports is importable from `fastapi_injected` itself, and that is the surface a release keeps:
@@ -338,10 +404,10 @@ Everything the package supports is importable from `fastapi_injected` itself, an
338
404
  | --- | --- |
339
405
  | Markers | `Dep`, `DepFactory`, `DepOf`, `Arg`, `Given`, `Injected` |
340
406
  | Resolving | `inject`, `resolve`, `bind_deps`, `signature_with_deps`, `remap_dep_args`, `clear_dependant_cache` |
341
- | Scopes and overrides | `InjectScope`, `push_inject_scope`, `inside_inject_scope`, `push_overrides`, `Overrides`, `OverridesProvider`, `ValueOverride`, `FactoryOverride` |
407
+ | Scopes and overrides | `InjectScope`, `push_inject_scope`, `inside_inject_scope`, `synthetic_request`, `push_overrides`, `Overrides`, `OverridesProvider`, `ValueOverride`, `FactoryOverride` |
342
408
  | Building on top | `MakeDataclass`, `MakeInjected`, `HasDependsHook`, `ArgMarker`, `is_arg`, `is_dep`, `unwrap_dep_tp`, `unwrap_dep_dependency` |
343
409
  | FastAPI integration | `add_injected_scope`, `init_inject_scope` |
344
- | Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError` |
410
+ | Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError`, `UnnamedParamError` |
345
411
 
346
412
  `fastapi_injected.types` holds the typing vocabulary the signatures are written in — `DepReturn`, `DepShape`, `DepDecl`, `AsyncFunc`, `Coro` and friends — and is public too. Anything else, including every module whose name starts with an underscore, is machinery that can change in a patch release.
347
413
 
@@ -8,12 +8,13 @@ from .deps import (
8
8
  DependencyResolutionError,
9
9
  HasDependsHook,
10
10
  MissedDependencyError,
11
+ UnnamedParamError,
11
12
  clear_dependant_cache,
12
13
  )
13
14
  from .inject import inject
14
15
  from .overrides import FactoryOverride, Overrides, OverridesProvider, ValueOverride, push_overrides
15
16
  from .resolve import resolve
16
- from .scope import InjectScope, UnboundScopeError, inside_inject_scope, push_inject_scope
17
+ from .scope import InjectScope, UnboundScopeError, inside_inject_scope, push_inject_scope, synthetic_request
17
18
  from .sign import NotADependencyError
18
19
  from .types import Arg, ArgMarker, Dep, DepFactory, DepOf, Injected
19
20
 
@@ -38,6 +39,7 @@ __all__ = [
38
39
  "OverridesProvider",
39
40
  "UnboundDepArgsError",
40
41
  "UnboundScopeError",
42
+ "UnnamedParamError",
41
43
  "ValueOverride",
42
44
  "add_injected_scope",
43
45
  "bind_deps",
@@ -52,6 +54,7 @@ __all__ = [
52
54
  "remap_dep_args",
53
55
  "resolve",
54
56
  "signature_with_deps",
57
+ "synthetic_request",
55
58
  "unwrap_dep_dependency",
56
59
  "unwrap_dep_tp",
57
60
  ]
@@ -1,12 +1,13 @@
1
1
  import inspect
2
2
  from collections.abc import Callable, Sequence
3
3
  from functools import wraps
4
- from typing import Annotated, Any, Concatenate, overload
4
+ from typing import Annotated, Any, Concatenate, get_origin, overload
5
5
 
6
6
  from fastapi.dependencies.utils import get_typed_signature
7
7
  from fastapi.params import Depends
8
8
 
9
- from ._deps_tp import is_dep
9
+ from ._calls import annotated
10
+ from ._deps_tp import unwrap_tp
10
11
  from .sign import update_func_sign
11
12
  from .types import AsyncFunc, DepOf, Func
12
13
 
@@ -33,16 +34,18 @@ def _dep_arg_index(name: str, /) -> int | None:
33
34
  return int(index) if index.isdigit() else None
34
35
 
35
36
 
37
+ def _is_annotation(tp: Any, /) -> bool:
38
+ return get_origin(unwrap_tp(tp)) is Annotated
39
+
40
+
36
41
  def _dep_annotation(dep: DepOf[Any] | Callable[..., Any], /) -> Any:
37
42
  if isinstance(dep, Depends):
38
- return Annotated[Any, dep]
43
+ return annotated(Any, dep)
39
44
 
40
- # a marker is an annotation already, while a callable is a dependency the same way
41
- # `resolve` takes one - anything else is left as it was written
42
- if is_dep(dep) or not callable(dep):
45
+ if _is_annotation(dep) or not callable(dep):
43
46
  return dep
44
47
 
45
- return Annotated[Any, Depends(dep)]
48
+ return annotated(Any, Depends(dep))
46
49
 
47
50
 
48
51
  def signature_with_deps(
@@ -0,0 +1,108 @@
1
+ import email.message
2
+ import json
3
+ from contextlib import AsyncExitStack
4
+ from typing import Any
5
+
6
+ from fastapi import Request, params
7
+ from fastapi.dependencies.models import Dependant
8
+ from fastapi.dependencies.utils import _get_flat_body_params, _should_embed_body_fields
9
+ from fastapi.routing import APIRoute
10
+ from starlette.datastructures import FormData
11
+
12
+ from .types import BoundConnection
13
+
14
+ type RequestBody = tuple[Any, bool]
15
+
16
+
17
+ class InvalidBodyError(ValueError):
18
+ def __init__(self, errors: list[Any], /) -> None:
19
+ super().__init__(errors)
20
+ self.errors = errors
21
+
22
+
23
+ def _is_json_content_type(content_type: str | None, /) -> bool:
24
+ if not content_type:
25
+ return False
26
+
27
+ message = email.message.Message()
28
+ message["content-type"] = content_type
29
+
30
+ if message.get_content_maintype() != "application":
31
+ return False
32
+
33
+ subtype = message.get_content_subtype()
34
+ return subtype == "json" or subtype.endswith("+json")
35
+
36
+
37
+ async def _read_form(request: Request, /) -> FormData:
38
+ parsed = request._form is not None # noqa: SLF001
39
+
40
+ form = await request.form()
41
+
42
+ if not parsed:
43
+ stack: AsyncExitStack = request.scope["fastapi_middleware_astack"]
44
+ stack.push_async_callback(form.close)
45
+
46
+ return form
47
+
48
+
49
+ async def _read_body(request: Request, *, is_form: bool) -> Any:
50
+ if is_form:
51
+ return await _read_form(request)
52
+
53
+ if not (body := await request.body()):
54
+ return None
55
+
56
+ if not _is_json_content_type(request.headers.get("content-type")):
57
+ return body
58
+
59
+ try:
60
+ return await request.json()
61
+ except json.JSONDecodeError as exc:
62
+ raise InvalidBodyError(
63
+ [
64
+ {
65
+ "type": "json_invalid",
66
+ "loc": ("body", exc.pos),
67
+ "msg": "JSON decode error",
68
+ "input": {},
69
+ "ctx": {"error": exc.msg},
70
+ },
71
+ ],
72
+ ) from exc
73
+
74
+
75
+ def _body_shape(request: Request, dependant: Dependant, /) -> tuple[bool, bool] | None:
76
+ route = request.scope.get("route")
77
+
78
+ match route:
79
+ case APIRoute(body_field=body_field) if body_field is not None:
80
+ return (
81
+ isinstance(body_field.field_info, params.Form),
82
+ route._embed_body_fields, # noqa: SLF001
83
+ )
84
+
85
+ if not (body_params := _get_flat_body_params(dependant)):
86
+ return None
87
+
88
+ is_form = any(isinstance(field.field_info, params.Form) for field in body_params)
89
+
90
+ return is_form, _should_embed_body_fields(body_params)
91
+
92
+
93
+ async def request_body(request: BoundConnection, dependant: Dependant, /) -> RequestBody:
94
+ if not isinstance(request, Request) or (shape := _body_shape(request, dependant)) is None:
95
+ return None, False
96
+
97
+ is_form, embed_body_fields = shape
98
+
99
+ return (
100
+ await _read_body(request, is_form=is_form),
101
+ embed_body_fields,
102
+ )
103
+
104
+
105
+ __all__ = [
106
+ "InvalidBodyError",
107
+ "request_body",
108
+ ]
@@ -5,6 +5,7 @@ from typing import Any
5
5
  from fastapi.dependencies.models import Dependant
6
6
  from fastapi.types import DependencyCacheKey
7
7
 
8
+ from ._calls import unwrap_proxy
8
9
  from ._dataclass import MakeDataclass
9
10
  from .types import DependencyCache
10
11
 
@@ -16,13 +17,14 @@ def overridden_calls(dependant: Dependant, overrides: Collection[Any], /) -> fro
16
17
  overridden: set[Any] = set()
17
18
 
18
19
  def _visit(dep: Dependant, /) -> bool:
19
- is_overridden = dep.call is not None and dep.call in overrides
20
+ call = unwrap_proxy(dep.call)
21
+ is_overridden = call is not None and call in overrides
20
22
 
21
23
  for sub_dep in dep.dependencies:
22
24
  is_overridden = _visit(sub_dep) or is_overridden
23
25
 
24
- if is_overridden and dep.call is not None:
25
- overridden.add(dep.call)
26
+ if is_overridden and call is not None:
27
+ overridden.add(call)
26
28
 
27
29
  return is_overridden
28
30
 
@@ -30,6 +32,14 @@ def overridden_calls(dependant: Dependant, overrides: Collection[Any], /) -> fro
30
32
  return frozenset(overridden)
31
33
 
32
34
 
35
+ def _cache_key(key: DependencyCacheKey, /) -> DependencyCacheKey:
36
+ # kept under the call a proxy stands for: the proxy is released once its resolve is
37
+ # done, the entry has to stay reachable for the rest of the scope
38
+ call, *rest = key
39
+
40
+ return (unwrap_proxy(call), *rest) # type: ignore[ty:invalid-return-type]
41
+
42
+
33
43
  class ScopeCache(MakeDataclass, MutableMapping[DependencyCacheKey, Any]):
34
44
  cache: DependencyCache
35
45
  fallbacks: Sequence[tuple[Mapping[DependencyCacheKey, Any], frozenset[Any]]] = field(default_factory=tuple)
@@ -42,6 +52,8 @@ class ScopeCache(MakeDataclass, MutableMapping[DependencyCacheKey, Any]):
42
52
  yield cache
43
53
 
44
54
  def __getitem__(self, key: DependencyCacheKey) -> Any:
55
+ key = _cache_key(key)
56
+
45
57
  try:
46
58
  return self.cache[key]
47
59
  except KeyError:
@@ -56,10 +68,10 @@ class ScopeCache(MakeDataclass, MutableMapping[DependencyCacheKey, Any]):
56
68
  raise KeyError(key)
57
69
 
58
70
  def __setitem__(self, key: DependencyCacheKey, value: Any) -> None:
59
- self.cache[key] = value
71
+ self.cache[_cache_key(key)] = value
60
72
 
61
73
  def __delitem__(self, key: DependencyCacheKey) -> None:
62
- del self.cache[key]
74
+ del self.cache[_cache_key(key)]
63
75
 
64
76
  def __iter__(self) -> Iterator[DependencyCacheKey]:
65
77
  seen = set(self.cache)
@@ -0,0 +1,192 @@
1
+ import dataclasses
2
+ import inspect
3
+ import typing
4
+ from collections.abc import Callable, Iterator, Mapping
5
+ from typing import Any, get_args, get_origin
6
+
7
+ from fastapi.dependencies.utils import get_typed_signature
8
+ from fastapi.params import Depends
9
+
10
+ from ._deps_tp import unwrap_tp
11
+
12
+
13
+ def annotated(tp: Any, /, *metadata: Any) -> Any:
14
+ # `Annotated[...]` goes through typing's own cache, which holds on to the last
15
+ # aliases it built - and with them whatever value a runtime marker carries
16
+ return typing._AnnotatedAlias(tp, metadata) # type: ignore[ty:unresolved-attribute] # noqa: SLF001
17
+
18
+
19
+ def is_static_call(call: Any, /) -> bool:
20
+ # what source code wrote down lives as long as the process anyway, so caching
21
+ # by it keeps nothing extra alive - an instance or a closure may carry request data
22
+ if inspect.isclass(call):
23
+ return True
24
+
25
+ # a builtin bound to an instance, like `cache.get`, carries that instance along
26
+ if inspect.isbuiltin(call):
27
+ return call.__self__ is None or inspect.ismodule(call.__self__)
28
+
29
+ # `__qualname__` is what `functools.wraps` copied over, the code knows where it was defined
30
+ if inspect.isfunction(call):
31
+ return "<locals>" not in call.__code__.co_qualname
32
+
33
+ return False
34
+
35
+
36
+ def _depends_metadata(annotation: Any, /) -> tuple[Any, list[Any]] | None:
37
+ annotation = unwrap_tp(annotation)
38
+
39
+ if get_origin(annotation) is not typing.Annotated:
40
+ return None
41
+
42
+ tp, *metadata = get_args(annotation)
43
+
44
+ return tp, metadata
45
+
46
+
47
+ def is_static_annotation(annotation: Any, /) -> bool:
48
+ match _depends_metadata(annotation):
49
+ case (tp, metadata):
50
+ return all(
51
+ is_static_call(tp if meta.dependency is None else meta.dependency)
52
+ for meta in metadata
53
+ if isinstance(meta, Depends)
54
+ )
55
+ case _:
56
+ return False
57
+
58
+
59
+ class CallProxy:
60
+ # what FastAPI is handed in place of a call made at runtime: its classification
61
+ # caches keep every call they are shown, and once released this is all they keep
62
+ __slots__ = ("_hash", "_proxies", "_signature", "call")
63
+
64
+ def __init__(self, call: Callable[..., Any], proxies: list["CallProxy"], /) -> None:
65
+ self.call: Callable[..., Any] | None = call
66
+ self._proxies = proxies
67
+ self._signature: inspect.Signature | None = None
68
+
69
+ try:
70
+ self._hash = hash(call)
71
+ except TypeError:
72
+ self._hash = id(call)
73
+
74
+ proxies.append(self)
75
+
76
+ @property
77
+ def __wrapped__(self) -> Callable[..., Any]:
78
+ # FastAPI unwraps a call to tell a coroutine from a generator, so it sees the
79
+ # real one - a released proxy has nothing left to show
80
+ if self.call is None:
81
+ raise AttributeError("__wrapped__")
82
+
83
+ return self.call
84
+
85
+ @property
86
+ def __signature__(self) -> inspect.Signature:
87
+ # FastAPI builds the dependencies of this call from its signature, so whatever
88
+ # was made at runtime among them is handed over behind a proxy as well
89
+ if self._signature is None:
90
+ sign = get_typed_signature(self.__wrapped__)
91
+ self._signature = sign.replace(
92
+ parameters=[self._detach_param(param) for param in sign.parameters.values()],
93
+ )
94
+
95
+ return self._signature
96
+
97
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
98
+ return self.__wrapped__(*args, **kwargs)
99
+
100
+ def __hash__(self) -> int:
101
+ return self._hash
102
+
103
+ def __eq__(self, other: object) -> bool:
104
+ # equal to the call it stands for, so it finds the same cache entries and overrides
105
+ if self.call is None:
106
+ return other is self
107
+
108
+ return self.call == unwrap_proxy(other)
109
+
110
+ def release(self) -> None:
111
+ self.call = None
112
+ self._signature = None
113
+ self._proxies = []
114
+
115
+ def _detach_depends(self, depends: Depends, /) -> Depends:
116
+ if depends.dependency is None or is_static_call(depends.dependency):
117
+ return depends
118
+
119
+ return dataclasses.replace(depends, dependency=CallProxy(depends.dependency, self._proxies))
120
+
121
+ def _detach_param(self, param: inspect.Parameter, /) -> inspect.Parameter:
122
+ if isinstance(param.default, Depends):
123
+ param = param.replace(default=self._detach_depends(param.default))
124
+
125
+ match _depends_metadata(param.annotation):
126
+ case (tp, metadata):
127
+ detached = [self._detach_depends(meta) if isinstance(meta, Depends) else meta for meta in metadata]
128
+
129
+ if any(new is not old for new, old in zip(detached, metadata, strict=True)):
130
+ param = param.replace(annotation=annotated(tp, *detached))
131
+
132
+ return param
133
+
134
+
135
+ class DetachedOverrides(Mapping[Any, Any]):
136
+ # an override replaces a call inside FastAPI, past where a proxy could be put on it,
137
+ # so the overrides of a resolve hand out what they hold behind one
138
+ __slots__ = ("_handed", "_overrides", "_proxies")
139
+
140
+ def __init__(self, overrides: Mapping[Any, Any], proxies: list[CallProxy], /) -> None:
141
+ self._overrides = overrides
142
+ self._proxies = proxies
143
+ self._handed: dict[int, CallProxy] = {}
144
+
145
+ @property
146
+ def dependency_overrides(self) -> Mapping[Any, Any]:
147
+ # FastAPI asks a provider for its overrides, and this is both
148
+ return self
149
+
150
+ def __getitem__(self, key: Any) -> Any:
151
+ override = self._overrides[key]
152
+
153
+ if is_static_call(override):
154
+ return override
155
+
156
+ if (proxy := self._handed.get(id(override))) is None:
157
+ proxy = self._handed[id(override)] = CallProxy(override, self._proxies)
158
+
159
+ return proxy
160
+
161
+ def __iter__(self) -> Iterator[Any]:
162
+ return iter(self._overrides)
163
+
164
+ def __len__(self) -> int:
165
+ return len(self._overrides)
166
+
167
+ def __bool__(self) -> bool:
168
+ # FastAPI takes the override path only when there are some, and asks at every step
169
+ return bool(self._overrides)
170
+
171
+
172
+ def release(proxies: list[CallProxy], /) -> None:
173
+ for proxy in proxies:
174
+ proxy.release()
175
+
176
+
177
+ def unwrap_proxy(call: Any, /) -> Any:
178
+ if isinstance(call, CallProxy) and call.call is not None:
179
+ return call.call
180
+
181
+ return call
182
+
183
+
184
+ __all__ = [
185
+ "CallProxy",
186
+ "DetachedOverrides",
187
+ "annotated",
188
+ "is_static_annotation",
189
+ "is_static_call",
190
+ "release",
191
+ "unwrap_proxy",
192
+ ]