fastapi-injected 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.
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/PKG-INFO +16 -1
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/README.md +15 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_bind.py +7 -5
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_fastapi_lifecycle.py +24 -7
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/scope.py +37 -7
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/types.py +4 -1
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/pyproject.toml +1 -1
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/integration.py +3 -2
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/scope.py +17 -4
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_bind.py +37 -3
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_fastapi.py +168 -1
- fastapi_injected-0.3.4/uv.lock +775 -0
- fastapi_injected-0.3.2/uv.lock +0 -776
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.github/dependabot.yml +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.github/workflows/automerge.yml +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.github/workflows/lint.yml +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.github/workflows/publish.yml +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.github/workflows/test.yml +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.gitignore +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/.pre-commit-config.yaml +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/LICENSE +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/__init__.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_cache.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_dataclass.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_deps_tp.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_given.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_injected.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_overrides.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/_rlock.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/deps.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/inject.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/overrides.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/resolve.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/fastapi_injected/sign.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/__init__.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/__init__.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/annotations.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/arg.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/bind.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/dataclass.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/deps.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/errors.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/given.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/inject.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/injected.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/markers.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/overrides.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/_typing/resolve.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/deps.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/ext/__init__.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/ext/test_pydantic_ai.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_cache.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_concurrency.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_dataclass.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_dependant_cache.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_errors.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_given.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_inject.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_injected.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_overrides.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_resolve.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/tests/test_rlock.py +0 -0
- {fastapi_injected-0.3.2 → fastapi_injected-0.3.4}/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.4
|
|
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>
|
|
@@ -351,6 +351,21 @@ async def route(service: Dep[Service]) -> str:
|
|
|
351
351
|
|
|
352
352
|
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
353
|
|
|
354
|
+
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()`:
|
|
355
|
+
|
|
356
|
+
```python
|
|
357
|
+
async def notify(websocket: WebSocket) -> None:
|
|
358
|
+
await websocket.send_json({"ready": True})
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@app.websocket("/ws")
|
|
362
|
+
async def ws_route(websocket: WebSocket, service: Dep[Service]) -> None:
|
|
363
|
+
await websocket.accept()
|
|
364
|
+
await resolve(notify) # sends through the connection the route accepted
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
`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
|
+
|
|
354
369
|
## What is public
|
|
355
370
|
|
|
356
371
|
Everything the package supports is importable from `fastapi_injected` itself, and that is the surface a release keeps:
|
|
@@ -330,6 +330,21 @@ async def route(service: Dep[Service]) -> str:
|
|
|
330
330
|
|
|
331
331
|
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
332
|
|
|
333
|
+
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()`:
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
async def notify(websocket: WebSocket) -> None:
|
|
337
|
+
await websocket.send_json({"ready": True})
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@app.websocket("/ws")
|
|
341
|
+
async def ws_route(websocket: WebSocket, service: Dep[Service]) -> None:
|
|
342
|
+
await websocket.accept()
|
|
343
|
+
await resolve(notify) # sends through the connection the route accepted
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`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
|
+
|
|
333
348
|
## What is public
|
|
334
349
|
|
|
335
350
|
Everything the package supports is importable from `fastapi_injected` itself, and that is the surface a release keeps:
|
|
@@ -1,12 +1,12 @@
|
|
|
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
|
|
9
|
+
from ._deps_tp import unwrap_tp
|
|
10
10
|
from .sign import update_func_sign
|
|
11
11
|
from .types import AsyncFunc, DepOf, Func
|
|
12
12
|
|
|
@@ -33,13 +33,15 @@ def _dep_arg_index(name: str, /) -> int | None:
|
|
|
33
33
|
return int(index) if index.isdigit() else None
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _is_annotation(tp: Any, /) -> bool:
|
|
37
|
+
return get_origin(unwrap_tp(tp)) is Annotated
|
|
38
|
+
|
|
39
|
+
|
|
36
40
|
def _dep_annotation(dep: DepOf[Any] | Callable[..., Any], /) -> Any:
|
|
37
41
|
if isinstance(dep, Depends):
|
|
38
42
|
return Annotated[Any, dep]
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
# `resolve` takes one - anything else is left as it was written
|
|
42
|
-
if is_dep(dep) or not callable(dep):
|
|
44
|
+
if _is_annotation(dep) or not callable(dep):
|
|
43
45
|
return dep
|
|
44
46
|
|
|
45
47
|
return Annotated[Any, Depends(dep)]
|
|
@@ -4,10 +4,11 @@ from typing import TYPE_CHECKING, Any
|
|
|
4
4
|
|
|
5
5
|
from fastapi import Depends, FastAPI, Request, WebSocket, routing
|
|
6
6
|
from fastapi.dependencies import utils
|
|
7
|
+
from starlette.requests import HTTPConnection
|
|
7
8
|
|
|
8
9
|
from ._cache import ScopeCache
|
|
9
10
|
from .scope import push_inject_scope
|
|
10
|
-
from .types import DependencyCache
|
|
11
|
+
from .types import DependencyCache, HasDependencyOverrides
|
|
11
12
|
|
|
12
13
|
_DEPENDENCY_CACHE_KEY = "__fastapi_injected_dependency_cache__"
|
|
13
14
|
|
|
@@ -46,18 +47,34 @@ if not TYPE_CHECKING:
|
|
|
46
47
|
routing.solve_dependencies = _solve_dependencies
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
def _get_dependency_cache(
|
|
50
|
+
def _get_dependency_cache(connection: HTTPConnection) -> DependencyCache:
|
|
50
51
|
try:
|
|
51
|
-
return
|
|
52
|
+
return connection.scope[_DEPENDENCY_CACHE_KEY]
|
|
52
53
|
except KeyError:
|
|
53
54
|
raise MissingDependencyCacheError("Dependency cache not found") from None
|
|
54
55
|
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
def _get_overrides_provider(connection: HTTPConnection) -> HasDependencyOverrides | None:
|
|
58
|
+
route = connection.scope.get("route")
|
|
59
|
+
|
|
60
|
+
if (provider := getattr(route, "dependency_overrides_provider", None)) is not None:
|
|
61
|
+
return provider
|
|
62
|
+
|
|
63
|
+
# a websocket route does not keep the provider it was given - but it is the app
|
|
64
|
+
# the route was added to, which is what the scope of the connection holds
|
|
65
|
+
return connection.scope.get("app")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# annotated as the base class so FastAPI hands over whichever the route has -
|
|
69
|
+
# a `Request` in an HTTP route, a `WebSocket` in a websocket one
|
|
70
|
+
async def init_inject_scope(connection: HTTPConnection) -> AsyncGenerator[None]:
|
|
71
|
+
if not isinstance(connection, Request | WebSocket): # pragma: no cover
|
|
72
|
+
raise TypeError(f"expected a Request or a WebSocket, got {type(connection).__name__}")
|
|
73
|
+
|
|
57
74
|
async with push_inject_scope(
|
|
58
|
-
dependency_cache=_get_dependency_cache(
|
|
59
|
-
request=
|
|
60
|
-
provider=
|
|
75
|
+
dependency_cache=_get_dependency_cache(connection),
|
|
76
|
+
request=connection,
|
|
77
|
+
provider=_get_overrides_provider(connection),
|
|
61
78
|
):
|
|
62
79
|
yield
|
|
63
80
|
|
|
@@ -10,16 +10,18 @@ from contextvars import ContextVar
|
|
|
10
10
|
from dataclasses import field, replace
|
|
11
11
|
from typing import Any, Self, cast
|
|
12
12
|
|
|
13
|
-
from fastapi import FastAPI, Request
|
|
13
|
+
from fastapi import FastAPI, Request, WebSocket
|
|
14
14
|
from fastapi.dependencies.models import Dependant
|
|
15
15
|
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
16
|
+
from starlette.requests import HTTPConnection
|
|
16
17
|
from starlette.types import Message, Scope
|
|
18
|
+
from starlette.websockets import WebSocketState
|
|
17
19
|
|
|
18
20
|
from ._cache import ScopeCache, overridden_calls
|
|
19
21
|
from ._dataclass import MakeDataclass
|
|
20
22
|
from ._overrides import Overrides, normalize_overrides
|
|
21
23
|
from ._rlock import RLock
|
|
22
|
-
from .types import DependencyCache, HasDependencyOverrides
|
|
24
|
+
from .types import BoundConnection, DependencyCache, HasDependencyOverrides
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class UnboundScopeError(Exception):
|
|
@@ -87,14 +89,42 @@ def _dummy_request(
|
|
|
87
89
|
)
|
|
88
90
|
|
|
89
91
|
|
|
92
|
+
class _ReboundWebSocket(WebSocket):
|
|
93
|
+
def __init__(self, origin: WebSocket, /, *, scope: Scope) -> None:
|
|
94
|
+
HTTPConnection.__init__(self, scope)
|
|
95
|
+
|
|
96
|
+
self._origin = origin
|
|
97
|
+
self._receive = origin._receive # noqa: SLF001
|
|
98
|
+
self._send = origin._send # noqa: SLF001
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def client_state(self) -> WebSocketState:
|
|
102
|
+
return self._origin.client_state
|
|
103
|
+
|
|
104
|
+
@client_state.setter
|
|
105
|
+
def client_state(self, state: WebSocketState) -> None:
|
|
106
|
+
self._origin.client_state = state
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def application_state(self) -> WebSocketState:
|
|
110
|
+
return self._origin.application_state
|
|
111
|
+
|
|
112
|
+
@application_state.setter
|
|
113
|
+
def application_state(self, state: WebSocketState) -> None:
|
|
114
|
+
self._origin.application_state = state
|
|
115
|
+
|
|
116
|
+
|
|
90
117
|
def _rebind_request(
|
|
91
|
-
request:
|
|
118
|
+
request: BoundConnection,
|
|
92
119
|
/,
|
|
93
120
|
*,
|
|
94
121
|
extra_scope: Scope,
|
|
95
|
-
) ->
|
|
122
|
+
) -> BoundConnection:
|
|
96
123
|
scope = {**request.scope, **extra_scope}
|
|
97
124
|
|
|
125
|
+
if isinstance(request, WebSocket):
|
|
126
|
+
return _ReboundWebSocket(request, scope=scope)
|
|
127
|
+
|
|
98
128
|
return Request(
|
|
99
129
|
scope,
|
|
100
130
|
receive=request.receive,
|
|
@@ -104,7 +134,7 @@ def _rebind_request(
|
|
|
104
134
|
|
|
105
135
|
class InjectScope(MakeDataclass):
|
|
106
136
|
dependency_cache: DependencyCache = field(default_factory=dict)
|
|
107
|
-
request:
|
|
137
|
+
request: BoundConnection | None = None
|
|
108
138
|
|
|
109
139
|
parent: Self | None = field(default=None, repr=False)
|
|
110
140
|
overrides: MutableMapping[Any, Any] = field(default_factory=dict, repr=False)
|
|
@@ -120,7 +150,7 @@ class InjectScope(MakeDataclass):
|
|
|
120
150
|
return self.request is not None
|
|
121
151
|
|
|
122
152
|
@property
|
|
123
|
-
def bound_request(self) ->
|
|
153
|
+
def bound_request(self) -> BoundConnection:
|
|
124
154
|
if self.request is None:
|
|
125
155
|
raise UnboundScopeError
|
|
126
156
|
|
|
@@ -214,7 +244,7 @@ async def push_inject_scope(
|
|
|
214
244
|
/,
|
|
215
245
|
*,
|
|
216
246
|
dependency_cache: DependencyCache | None = None,
|
|
217
|
-
request:
|
|
247
|
+
request: BoundConnection | None = None,
|
|
218
248
|
app: FastAPI | None = None,
|
|
219
249
|
provider: HasDependencyOverrides | None = None,
|
|
220
250
|
) -> AsyncIterator[InjectScope]:
|
|
@@ -2,7 +2,7 @@ import inspect
|
|
|
2
2
|
from collections.abc import AsyncIterator, Callable, Coroutine, Iterator, Mapping
|
|
3
3
|
from typing import TYPE_CHECKING, Annotated, Any, Protocol, TypeVar, runtime_checkable
|
|
4
4
|
|
|
5
|
-
from fastapi import Depends
|
|
5
|
+
from fastapi import Depends, Request, WebSocket
|
|
6
6
|
from fastapi.types import DependencyCacheKey
|
|
7
7
|
from typing_extensions import TypeForm, sentinel
|
|
8
8
|
|
|
@@ -56,6 +56,8 @@ type AsyncDecorator[**P, R] = Callable[
|
|
|
56
56
|
AsyncFunc[P, R],
|
|
57
57
|
]
|
|
58
58
|
|
|
59
|
+
type BoundConnection = Request | WebSocket
|
|
60
|
+
|
|
59
61
|
type DependencyCache = dict[DependencyCacheKey, Any]
|
|
60
62
|
|
|
61
63
|
|
|
@@ -82,6 +84,7 @@ __all__ = [
|
|
|
82
84
|
"ArgMarker",
|
|
83
85
|
"AsyncDecorator",
|
|
84
86
|
"AsyncFunc",
|
|
87
|
+
"BoundConnection",
|
|
85
88
|
"Coro",
|
|
86
89
|
"Decorator",
|
|
87
90
|
"Dep",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from collections.abc import AsyncGenerator
|
|
2
2
|
|
|
3
|
-
from fastapi import Depends, FastAPI, Request
|
|
3
|
+
from fastapi import Depends, FastAPI, Request, WebSocket
|
|
4
4
|
|
|
5
5
|
from fastapi_injected import Dep, add_injected_scope, init_inject_scope
|
|
6
6
|
|
|
@@ -17,8 +17,9 @@ app = FastAPI(dependencies=[Depends(init_inject_scope)])
|
|
|
17
17
|
static_assert(is_equivalent_to(TypeOf[add_injected_scope(app)], None))
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def _init_scope_is_a_generator_dependency(request: Request) -> None:
|
|
20
|
+
def _init_scope_is_a_generator_dependency(request: Request, websocket: WebSocket) -> None:
|
|
21
21
|
static_assert(is_equivalent_to(TypeOf[init_inject_scope(request)], AsyncGenerator[None]))
|
|
22
|
+
static_assert(is_equivalent_to(TypeOf[init_inject_scope(websocket)], AsyncGenerator[None]))
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
@app.get("/")
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
from collections.abc import MutableMapping
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
-
from fastapi import Request
|
|
4
|
+
from fastapi import Request, WebSocket
|
|
5
5
|
|
|
6
6
|
from fastapi_injected import push_inject_scope
|
|
7
7
|
from fastapi_injected.scope import InjectScope, inside_inject_scope
|
|
8
|
-
from fastapi_injected.types import DependencyCache
|
|
8
|
+
from fastapi_injected.types import BoundConnection, DependencyCache
|
|
9
9
|
|
|
10
10
|
from .deps import (
|
|
11
11
|
TypeOf,
|
|
@@ -19,8 +19,9 @@ async def _scope() -> None:
|
|
|
19
19
|
static_assert(is_equivalent_to(TypeOf[scope], InjectScope))
|
|
20
20
|
static_assert(is_equivalent_to(TypeOf[scope.dependency_cache], DependencyCache))
|
|
21
21
|
# a scope pushed by `push_inject_scope` is always bound, but the field is optional
|
|
22
|
-
static_assert(is_equivalent_to(TypeOf[scope.request],
|
|
23
|
-
static_assert(is_equivalent_to(TypeOf[scope.bound_request],
|
|
22
|
+
static_assert(is_equivalent_to(TypeOf[scope.request], BoundConnection | None))
|
|
23
|
+
static_assert(is_equivalent_to(TypeOf[scope.bound_request], BoundConnection))
|
|
24
|
+
static_assert(is_equivalent_to(TypeOf[scope.bound_request], Request | WebSocket))
|
|
24
25
|
static_assert(is_equivalent_to(TypeOf[scope.bound], bool))
|
|
25
26
|
static_assert(is_equivalent_to(TypeOf[scope.path_format], str | None))
|
|
26
27
|
static_assert(is_equivalent_to(TypeOf[scope.synthetic], bool))
|
|
@@ -42,6 +43,15 @@ async def _scope() -> None:
|
|
|
42
43
|
static_assert(is_equivalent_to(TypeOf[nested], InjectScope))
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
async def _bound_to(request: Request, websocket: WebSocket) -> None:
|
|
47
|
+
# a scope is bound to whichever the route has - a request or a websocket
|
|
48
|
+
async with push_inject_scope(request=request):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
async with push_inject_scope(request=websocket):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
45
55
|
def _current_scope() -> None:
|
|
46
56
|
# outside of any scope there is none, so the result is optional
|
|
47
57
|
static_assert(is_equivalent_to(TypeOf[InjectScope.current()], InjectScope | None))
|
|
@@ -52,3 +62,6 @@ async def _negatives() -> None:
|
|
|
52
62
|
pass
|
|
53
63
|
|
|
54
64
|
_ = InjectScope.current().dependency_cache # type: ignore[ty:unresolved-attribute]
|
|
65
|
+
|
|
66
|
+
async with push_inject_scope(request=object()): # type: ignore[ty:invalid-argument-type]
|
|
67
|
+
pass
|
|
@@ -2,9 +2,19 @@ from dataclasses import dataclass
|
|
|
2
2
|
from typing import Annotated, Any
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
|
-
from fastapi import Depends
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
from fastapi import Depends, FastAPI, Header
|
|
6
|
+
from fastapi.testclient import TestClient
|
|
7
|
+
|
|
8
|
+
from fastapi_injected import (
|
|
9
|
+
Given,
|
|
10
|
+
bind_deps,
|
|
11
|
+
init_inject_scope,
|
|
12
|
+
inject,
|
|
13
|
+
push_inject_scope,
|
|
14
|
+
push_overrides,
|
|
15
|
+
resolve,
|
|
16
|
+
signature_with_deps,
|
|
17
|
+
)
|
|
8
18
|
from fastapi_injected._bind import UnboundDepArgsError, dep_arg_name, remap_dep_args, take_dep_args
|
|
9
19
|
|
|
10
20
|
pytestmark = pytest.mark.asyncio
|
|
@@ -87,6 +97,30 @@ async def test_a_dependency_can_be_bound_however_it_is_written(dep):
|
|
|
87
97
|
assert await resolve(bind_deps(describe, Given(Doc()), dep)) == "readme:admin"
|
|
88
98
|
|
|
89
99
|
|
|
100
|
+
TitleHeader = Annotated[str, Header(alias="x-title")]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def titled(title: str, role: Role) -> str:
|
|
104
|
+
return f"{title}:{role.name}"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_a_request_marker_is_bound_as_written() -> None:
|
|
108
|
+
sign = signature_with_deps(titled, TitleHeader, RoleDep)
|
|
109
|
+
|
|
110
|
+
assert next(iter(sign.parameters.values())).annotation == TitleHeader
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_a_request_marker_resolves_from_the_request() -> None:
|
|
114
|
+
app = FastAPI(dependencies=[Depends(init_inject_scope)])
|
|
115
|
+
|
|
116
|
+
@app.get("/marker")
|
|
117
|
+
async def _route(described: Annotated[str, Depends(bind_deps(titled, TitleHeader, RoleDep))]) -> str:
|
|
118
|
+
return described
|
|
119
|
+
|
|
120
|
+
with TestClient(app) as client:
|
|
121
|
+
assert client.get("/marker", headers={"x-title": "readme"}).json() == "readme:admin"
|
|
122
|
+
|
|
123
|
+
|
|
90
124
|
async def test_signature_with_deps_names_the_bound_parameters() -> None:
|
|
91
125
|
sign = signature_with_deps(describe, Given(Doc()), RoleDep)
|
|
92
126
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
from typing import Annotated
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi import Depends, FastAPI, Header, Request, WebSocket, WebSocketDisconnect, status
|
|
4
5
|
from fastapi.testclient import TestClient
|
|
6
|
+
from starlette.requests import HTTPConnection
|
|
7
|
+
from starlette.websockets import WebSocketState
|
|
5
8
|
|
|
6
9
|
from fastapi_injected import Dep, Injected, init_inject_scope, inject, push_inject_scope, resolve
|
|
7
10
|
from fastapi_injected._fastapi_lifecycle import add_injected_scope
|
|
@@ -178,6 +181,23 @@ def test_app_dependency_overrides_are_used() -> None:
|
|
|
178
181
|
assert result.status_code == status.HTTP_200_OK
|
|
179
182
|
|
|
180
183
|
|
|
184
|
+
@overridden_app.websocket("/ws")
|
|
185
|
+
async def overridden_ws_route(websocket: WebSocket, child: Dep[Child]) -> None:
|
|
186
|
+
await websocket.accept()
|
|
187
|
+
await websocket.send_json({"overridden": child is _overridden_child and await resolve(Child) is child})
|
|
188
|
+
await websocket.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_app_dependency_overrides_are_used_in_websocket() -> None:
|
|
192
|
+
overridden_app.dependency_overrides[Child] = _override_child
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
with overridden_client.websocket_connect("/ws") as ws:
|
|
196
|
+
assert ws.receive_json() == {"overridden": True}
|
|
197
|
+
finally:
|
|
198
|
+
overridden_app.dependency_overrides.clear()
|
|
199
|
+
|
|
200
|
+
|
|
181
201
|
async def _header_dep(x_trace: Annotated[str, Header()] = "missing") -> str:
|
|
182
202
|
return x_trace
|
|
183
203
|
|
|
@@ -211,3 +231,150 @@ def test_new_scope_is_fresh_and_keeps_the_request() -> None:
|
|
|
211
231
|
|
|
212
232
|
assert result.status_code == status.HTTP_200_OK
|
|
213
233
|
assert result.json() == {"is_fresh": True, "keeps_request": True}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@inject
|
|
237
|
+
async def _ws_func(
|
|
238
|
+
*,
|
|
239
|
+
container: Dep[Container] = Injected,
|
|
240
|
+
) -> Container:
|
|
241
|
+
return container
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.websocket("/ws")
|
|
245
|
+
async def ws_route(websocket: WebSocket, container: Dep[Container]) -> None:
|
|
246
|
+
await websocket.accept()
|
|
247
|
+
|
|
248
|
+
same = await resolve(Container) is container and await _ws_func() is container
|
|
249
|
+
|
|
250
|
+
await websocket.send_json({"same": same})
|
|
251
|
+
await websocket.close()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_websocket_cache_is_working() -> None:
|
|
255
|
+
with client.websocket_connect("/ws") as ws:
|
|
256
|
+
assert ws.receive_json() == {"same": True}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.websocket("/ws/items/{item_id}")
|
|
260
|
+
async def ws_item_route(websocket: WebSocket, item_id: ItemID) -> None:
|
|
261
|
+
await websocket.accept()
|
|
262
|
+
await websocket.send_json({"item_id": await resolve(ItemID) == item_id, "value": item_id})
|
|
263
|
+
await websocket.close()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_websocket_resolve_uses_route_path() -> None:
|
|
267
|
+
with client.websocket_connect("/ws/items/42") as ws:
|
|
268
|
+
assert ws.receive_json() == {"item_id": True, "value": 42}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def _ws_dep(websocket: WebSocket) -> WebSocket:
|
|
272
|
+
return websocket
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _connection_dep(connection: HTTPConnection) -> HTTPConnection:
|
|
276
|
+
return connection
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
type WS = Annotated[WebSocket, Depends(_ws_dep)]
|
|
280
|
+
type Connection = Annotated[HTTPConnection, Depends(_connection_dep)]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.websocket("/ws/send")
|
|
284
|
+
async def ws_send_route(websocket: WebSocket) -> None:
|
|
285
|
+
await websocket.accept()
|
|
286
|
+
|
|
287
|
+
scope = InjectScope.current()
|
|
288
|
+
assert scope is not None
|
|
289
|
+
assert scope.request is not websocket
|
|
290
|
+
|
|
291
|
+
async with push_inject_scope(request=websocket) as nested:
|
|
292
|
+
assert nested.request is not websocket
|
|
293
|
+
assert not nested.synthetic
|
|
294
|
+
|
|
295
|
+
resolved = await resolve(WS)
|
|
296
|
+
connection = await resolve(Connection)
|
|
297
|
+
|
|
298
|
+
assert isinstance(connection, WebSocket)
|
|
299
|
+
assert resolved.scope["path"] == websocket.scope["path"]
|
|
300
|
+
|
|
301
|
+
await resolved.send_json({"from": "dependency"})
|
|
302
|
+
|
|
303
|
+
await websocket.send_json({"from": "route"})
|
|
304
|
+
await websocket.close()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_websocket_dependency_shares_the_handshake() -> None:
|
|
308
|
+
with client.websocket_connect("/ws/send") as ws:
|
|
309
|
+
assert ws.receive_json() == {"from": "dependency"}
|
|
310
|
+
assert ws.receive_json() == {"from": "route"}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@app.websocket("/ws/close")
|
|
314
|
+
async def ws_close_route(websocket: WebSocket) -> None:
|
|
315
|
+
await websocket.accept()
|
|
316
|
+
|
|
317
|
+
resolved = await resolve(WS)
|
|
318
|
+
await resolved.close()
|
|
319
|
+
|
|
320
|
+
# closing through the bound websocket closed the one the route holds too
|
|
321
|
+
assert websocket.application_state is WebSocketState.DISCONNECTED
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_websocket_close_through_dependency_is_seen_by_the_route() -> None:
|
|
325
|
+
with client.websocket_connect("/ws/close") as ws, pytest.raises(WebSocketDisconnect):
|
|
326
|
+
ws.receive_json()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.websocket("/ws/state")
|
|
330
|
+
async def ws_state_route(websocket: WebSocket) -> None:
|
|
331
|
+
await websocket.accept()
|
|
332
|
+
websocket.state.value = 42
|
|
333
|
+
|
|
334
|
+
async with push_inject_scope(request=websocket) as scope:
|
|
335
|
+
assert scope.request.state.value == 42
|
|
336
|
+
scope.request.state.value = 43
|
|
337
|
+
|
|
338
|
+
await websocket.send_json({"value": websocket.state.value})
|
|
339
|
+
await websocket.close()
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def test_websocket_nested_scope_shares_state() -> None:
|
|
343
|
+
with client.websocket_connect("/ws/state") as ws:
|
|
344
|
+
assert ws.receive_json() == {"value": 43}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
_ws_states: list[ContextState] = []
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.websocket("/ws/teardown")
|
|
351
|
+
async def ws_teardown_route(websocket: WebSocket) -> None:
|
|
352
|
+
await websocket.accept()
|
|
353
|
+
|
|
354
|
+
_ws_states.append(await resolve(ctx_dep))
|
|
355
|
+
await websocket.send_json({"closed": _ws_states[-1].closed})
|
|
356
|
+
await websocket.close()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_websocket_dependencies_are_torn_down_with_the_connection() -> None:
|
|
360
|
+
_ws_states.clear()
|
|
361
|
+
|
|
362
|
+
with client.websocket_connect("/ws/teardown") as ws:
|
|
363
|
+
assert ws.receive_json() == {"closed": False}
|
|
364
|
+
|
|
365
|
+
assert [state.closed for state in _ws_states] == [True]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.websocket("/ws/new-scope")
|
|
369
|
+
async def ws_new_scope_route(websocket: WebSocket, container: Dep[Container]) -> None:
|
|
370
|
+
await websocket.accept()
|
|
371
|
+
|
|
372
|
+
fresh, _ = await _in_new_scope()
|
|
373
|
+
|
|
374
|
+
await websocket.send_json({"is_fresh": fresh is not container})
|
|
375
|
+
await websocket.close()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_websocket_new_scope_is_fresh() -> None:
|
|
379
|
+
with client.websocket_connect("/ws/new-scope") as ws:
|
|
380
|
+
assert ws.receive_json() == {"is_fresh": True}
|