fastapi-injected 0.3.3__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/PKG-INFO +16 -1
  2. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/README.md +15 -0
  3. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_fastapi_lifecycle.py +24 -7
  4. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/scope.py +37 -7
  5. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/types.py +4 -1
  6. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/pyproject.toml +1 -1
  7. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/integration.py +3 -2
  8. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/scope.py +17 -4
  9. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_fastapi.py +168 -1
  10. fastapi_injected-0.3.4/uv.lock +775 -0
  11. fastapi_injected-0.3.3/uv.lock +0 -776
  12. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.github/dependabot.yml +0 -0
  13. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.github/workflows/automerge.yml +0 -0
  14. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.github/workflows/lint.yml +0 -0
  15. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.github/workflows/publish.yml +0 -0
  16. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.github/workflows/test.yml +0 -0
  17. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.gitignore +0 -0
  18. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/.pre-commit-config.yaml +0 -0
  19. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/LICENSE +0 -0
  20. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/__init__.py +0 -0
  21. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_bind.py +0 -0
  22. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_cache.py +0 -0
  23. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_dataclass.py +0 -0
  24. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_deps_tp.py +0 -0
  25. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_given.py +0 -0
  26. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_injected.py +0 -0
  27. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_overrides.py +0 -0
  28. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/_rlock.py +0 -0
  29. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/deps.py +0 -0
  30. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/inject.py +0 -0
  31. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/overrides.py +0 -0
  32. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/resolve.py +0 -0
  33. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/fastapi_injected/sign.py +0 -0
  34. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/__init__.py +0 -0
  35. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/__init__.py +0 -0
  36. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/annotations.py +0 -0
  37. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/arg.py +0 -0
  38. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/bind.py +0 -0
  39. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/dataclass.py +0 -0
  40. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/deps.py +0 -0
  41. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/errors.py +0 -0
  42. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/given.py +0 -0
  43. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/inject.py +0 -0
  44. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/injected.py +0 -0
  45. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/markers.py +0 -0
  46. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/overrides.py +0 -0
  47. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/_typing/resolve.py +0 -0
  48. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/deps.py +0 -0
  49. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/ext/__init__.py +0 -0
  50. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/ext/test_pydantic_ai.py +0 -0
  51. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_bind.py +0 -0
  52. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_cache.py +0 -0
  53. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_concurrency.py +0 -0
  54. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_dataclass.py +0 -0
  55. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_dependant_cache.py +0 -0
  56. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_errors.py +0 -0
  57. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_given.py +0 -0
  58. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_inject.py +0 -0
  59. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_injected.py +0 -0
  60. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_overrides.py +0 -0
  61. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_resolve.py +0 -0
  62. {fastapi_injected-0.3.3 → fastapi_injected-0.3.4}/tests/test_rlock.py +0 -0
  63. {fastapi_injected-0.3.3 → 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
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:
@@ -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(request: Request) -> DependencyCache:
50
+ def _get_dependency_cache(connection: HTTPConnection) -> DependencyCache:
50
51
  try:
51
- return request.scope[_DEPENDENCY_CACHE_KEY]
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
- async def init_inject_scope(request: Request) -> AsyncGenerator[None]:
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(request),
59
- request=request,
60
- provider=request.scope["route"].dependency_overrides_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: Request,
118
+ request: BoundConnection,
92
119
  /,
93
120
  *,
94
121
  extra_scope: Scope,
95
- ) -> Request:
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: Request | None = None
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) -> Request:
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: Request | None = None,
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
  [project]
2
2
  name = "fastapi-injected"
3
- version = "0.3.3"
3
+ version = "0.3.4"
4
4
  description = "Yet another library to reuse fastapi dependency injection"
5
5
  authors = [
6
6
  { name = "Yurii Karabas", email = "1998uriyyo@gmail.com" },
@@ -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], Request | None))
23
- static_assert(is_equivalent_to(TypeOf[scope.bound_request], 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
@@ -1,7 +1,10 @@
1
1
  from typing import Annotated
2
2
 
3
- from fastapi import Depends, FastAPI, Header, Request, status
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}