fastapi-injected 0.3.4__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.
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/PKG-INFO +57 -6
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/README.md +55 -4
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/__init__.py +4 -1
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_bind.py +3 -2
- fastapi_injected-0.3.6/fastapi_injected/_body.py +108 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_cache.py +17 -5
- fastapi_injected-0.3.6/fastapi_injected/_calls.py +192 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_deps_tp.py +9 -1
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_given.py +3 -2
- fastapi_injected-0.3.6/fastapi_injected/deps.py +227 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/overrides.py +2 -2
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/resolve.py +7 -11
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/scope.py +97 -10
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/sign.py +18 -4
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/pyproject.toml +3 -2
- fastapi_injected-0.3.6/tests/test_body.py +522 -0
- fastapi_injected-0.3.6/tests/test_body_reads.py +263 -0
- fastapi_injected-0.3.6/tests/test_calls.py +117 -0
- fastapi_injected-0.3.6/tests/test_dependant_cache.py +249 -0
- fastapi_injected-0.3.6/tests/test_request_params.py +172 -0
- fastapi_injected-0.3.6/uv.lock +819 -0
- fastapi_injected-0.3.4/fastapi_injected/deps.py +0 -156
- fastapi_injected-0.3.4/tests/test_dependant_cache.py +0 -92
- fastapi_injected-0.3.4/uv.lock +0 -775
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.github/dependabot.yml +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.github/workflows/automerge.yml +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.github/workflows/lint.yml +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.github/workflows/publish.yml +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.github/workflows/test.yml +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.gitignore +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/.pre-commit-config.yaml +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/LICENSE +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_dataclass.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_fastapi_lifecycle.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_injected.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_overrides.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/_rlock.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/inject.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/fastapi_injected/types.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/__init__.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/__init__.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/annotations.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/arg.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/bind.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/dataclass.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/deps.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/errors.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/given.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/inject.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/injected.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/integration.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/markers.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/overrides.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/resolve.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/_typing/scope.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/deps.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/ext/__init__.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/ext/test_pydantic_ai.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_bind.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_cache.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_concurrency.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_dataclass.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_errors.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_fastapi.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_given.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_inject.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_injected.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_overrides.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_resolve.py +0 -0
- {fastapi_injected-0.3.4 → fastapi_injected-0.3.6}/tests/test_rlock.py +0 -0
- {fastapi_injected-0.3.4 → 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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -364,6 +382,39 @@ async def ws_route(websocket: WebSocket, service: Dep[Service]) -> None:
|
|
|
364
382
|
await resolve(notify) # sends through the connection the route accepted
|
|
365
383
|
```
|
|
366
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
|
+
|
|
367
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`.
|
|
368
419
|
|
|
369
420
|
## What is public
|
|
@@ -374,10 +425,10 @@ Everything the package supports is importable from `fastapi_injected` itself, an
|
|
|
374
425
|
| --- | --- |
|
|
375
426
|
| Markers | `Dep`, `DepFactory`, `DepOf`, `Arg`, `Given`, `Injected` |
|
|
376
427
|
| Resolving | `inject`, `resolve`, `bind_deps`, `signature_with_deps`, `remap_dep_args`, `clear_dependant_cache` |
|
|
377
|
-
| 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` |
|
|
378
429
|
| Building on top | `MakeDataclass`, `MakeInjected`, `HasDependsHook`, `ArgMarker`, `is_arg`, `is_dep`, `unwrap_dep_tp`, `unwrap_dep_dependency` |
|
|
379
430
|
| FastAPI integration | `add_injected_scope`, `init_inject_scope` |
|
|
380
|
-
| Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError` |
|
|
431
|
+
| Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError`, `UnnamedParamError` |
|
|
381
432
|
|
|
382
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.
|
|
383
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -343,6 +361,39 @@ async def ws_route(websocket: WebSocket, service: Dep[Service]) -> None:
|
|
|
343
361
|
await resolve(notify) # sends through the connection the route accepted
|
|
344
362
|
```
|
|
345
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
|
+
|
|
346
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`.
|
|
347
398
|
|
|
348
399
|
## What is public
|
|
@@ -353,10 +404,10 @@ Everything the package supports is importable from `fastapi_injected` itself, an
|
|
|
353
404
|
| --- | --- |
|
|
354
405
|
| Markers | `Dep`, `DepFactory`, `DepOf`, `Arg`, `Given`, `Injected` |
|
|
355
406
|
| Resolving | `inject`, `resolve`, `bind_deps`, `signature_with_deps`, `remap_dep_args`, `clear_dependant_cache` |
|
|
356
|
-
| 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` |
|
|
357
408
|
| Building on top | `MakeDataclass`, `MakeInjected`, `HasDependsHook`, `ArgMarker`, `is_arg`, `is_dep`, `unwrap_dep_tp`, `unwrap_dep_dependency` |
|
|
358
409
|
| FastAPI integration | `add_injected_scope`, `init_inject_scope` |
|
|
359
|
-
| Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError` |
|
|
410
|
+
| Errors | `DependencyResolutionError`, `MissedDependencyError`, `MissingDependencyCacheError`, `NotADependencyError`, `UnboundDepArgsError`, `UnboundScopeError`, `UnnamedParamError` |
|
|
360
411
|
|
|
361
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.
|
|
362
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
|
]
|
|
@@ -6,6 +6,7 @@ from typing import Annotated, Any, Concatenate, get_origin, overload
|
|
|
6
6
|
from fastapi.dependencies.utils import get_typed_signature
|
|
7
7
|
from fastapi.params import Depends
|
|
8
8
|
|
|
9
|
+
from ._calls import annotated
|
|
9
10
|
from ._deps_tp import unwrap_tp
|
|
10
11
|
from .sign import update_func_sign
|
|
11
12
|
from .types import AsyncFunc, DepOf, Func
|
|
@@ -39,12 +40,12 @@ def _is_annotation(tp: Any, /) -> bool:
|
|
|
39
40
|
|
|
40
41
|
def _dep_annotation(dep: DepOf[Any] | Callable[..., Any], /) -> Any:
|
|
41
42
|
if isinstance(dep, Depends):
|
|
42
|
-
return
|
|
43
|
+
return annotated(Any, dep)
|
|
43
44
|
|
|
44
45
|
if _is_annotation(dep) or not callable(dep):
|
|
45
46
|
return dep
|
|
46
47
|
|
|
47
|
-
return
|
|
48
|
+
return annotated(Any, Depends(dep))
|
|
48
49
|
|
|
49
50
|
|
|
50
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
|
-
|
|
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
|
|
25
|
-
overridden.add(
|
|
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
|
+
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from typing import Annotated, Any, TypeVar, get_args, get_origin
|
|
2
2
|
|
|
3
|
-
from fastapi.params import Depends
|
|
3
|
+
from fastapi.params import Body, Depends, Param
|
|
4
4
|
from typing_inspection.typing_objects import is_typealiastype
|
|
5
5
|
|
|
6
6
|
from .types import ArgMarker
|
|
@@ -27,6 +27,13 @@ def is_dep(tp: Any) -> bool:
|
|
|
27
27
|
return any(isinstance(tp, Depends) for tp in _get_annotated_metadata(tp))
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
def is_request_param(tp: Any) -> bool:
|
|
31
|
+
# a marker FastAPI reads from the request: `Header()`, `Query()`, `Path()`, `Cookie()`, the body ones
|
|
32
|
+
tp = unwrap_tp(tp)
|
|
33
|
+
|
|
34
|
+
return any(isinstance(tp, Param | Body) for tp in _get_annotated_metadata(tp))
|
|
35
|
+
|
|
36
|
+
|
|
30
37
|
def is_arg(tp: Any) -> bool:
|
|
31
38
|
tp = unwrap_tp(tp)
|
|
32
39
|
|
|
@@ -66,6 +73,7 @@ def unwrap_dep_dependency(obj: Any, /) -> Any:
|
|
|
66
73
|
__all__ = [
|
|
67
74
|
"is_arg",
|
|
68
75
|
"is_dep",
|
|
76
|
+
"is_request_param",
|
|
69
77
|
"unwrap_dep_dependency",
|
|
70
78
|
"unwrap_dep_tp",
|
|
71
79
|
"unwrap_tp",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
from typing import
|
|
1
|
+
from typing import Any, cast
|
|
2
2
|
|
|
3
3
|
from fastapi import Depends
|
|
4
4
|
|
|
5
|
+
from ._calls import annotated
|
|
5
6
|
from ._dataclass import MakeDataclass
|
|
6
7
|
from .types import DepOf
|
|
7
8
|
|
|
@@ -16,7 +17,7 @@ class _Constant[R](MakeDataclass):
|
|
|
16
17
|
def Given[R](value: R, /) -> DepOf[R]: # noqa: N802
|
|
17
18
|
# a dependency that was already resolved by whoever built it - constants that hold
|
|
18
19
|
# the same value are the same dependency, so they are cached and overridden alike
|
|
19
|
-
return cast("DepOf[R]",
|
|
20
|
+
return cast("DepOf[R]", annotated(Any, Depends(_Constant(value))))
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
__all__ = [
|