swiftpy-framework 0.1.0__py3-none-any.whl

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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,730 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from collections.abc import Mapping
7
+ from types import TracebackType
8
+ from typing import Any, Self
9
+ from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit
10
+
11
+ from swiftpy.http.response import ResponseHeaders
12
+ from swiftpy.websockets.connection import WebSocketDisconnect
13
+
14
+ logger = logging.getLogger("swiftpy.testing")
15
+
16
+
17
+ def _merge_headers_case_insensitive(
18
+ base: Mapping[str, str],
19
+ updates: Mapping[str, str] | None = None,
20
+ ) -> dict[str, str]:
21
+ """Merge HTTP headers case-insensitively while preserving the latest key casing."""
22
+ merged: dict[str, str] = {}
23
+ key_by_lower: dict[str, str] = {}
24
+ for source in (base, updates or {}):
25
+ for key, value in source.items():
26
+ lowered = key.lower()
27
+ prev_key = key_by_lower.get(lowered)
28
+ if prev_key is not None and prev_key != key:
29
+ del merged[prev_key]
30
+ key_by_lower[lowered] = key
31
+ merged[key] = value
32
+ return merged
33
+
34
+
35
+ def _build_path_and_query(
36
+ path: str,
37
+ query: Mapping[str, Any] | None = None,
38
+ ) -> tuple[str, bytes, bytes]:
39
+ """Parse URL path and inline query parameters, merging any explicit query mapping."""
40
+ if path.startswith(("http://", "https://", "ws://", "wss://")):
41
+ parts = urlsplit(path)
42
+ raw_path_part = parts.path or "/"
43
+ raw_qs_part = parts.query
44
+ else:
45
+ no_fragment = path.split("#", 1)[0]
46
+ raw_path_part, _, raw_qs_part = no_fragment.partition("?")
47
+ if not raw_path_part:
48
+ raw_path_part = "/"
49
+
50
+ decoded_path = unquote(raw_path_part)
51
+ encoded_raw_path = quote(raw_path_part, safe="/%:@!$&'()*+,;=-._~").encode("ascii")
52
+
53
+ query_pairs: list[tuple[str, Any]] = (
54
+ list(parse_qsl(raw_qs_part, keep_blank_values=True)) if raw_qs_part else []
55
+ )
56
+ if query:
57
+ query_pairs.extend(query.items())
58
+
59
+ query_string = (
60
+ urlencode(query_pairs, doseq=True, encoding="utf-8").encode("ascii")
61
+ if query_pairs
62
+ else b""
63
+ )
64
+ return decoded_path, encoded_raw_path, query_string
65
+
66
+
67
+ class TestResponse:
68
+ """
69
+ Encapsulates the HTTP response returned by TestClient.
70
+ Provides fluent assertion helpers for testing responses.
71
+ """
72
+
73
+ __test__ = False
74
+
75
+ def __init__(
76
+ self,
77
+ status_code: int,
78
+ headers: dict[str, str] | list[tuple[str, str]] | ResponseHeaders,
79
+ body: bytes,
80
+ ) -> None:
81
+ self.status_code = status_code
82
+ self.headers: ResponseHeaders = (
83
+ headers
84
+ if isinstance(headers, ResponseHeaders)
85
+ else ResponseHeaders(headers)
86
+ )
87
+ self.body = body
88
+ self._text: str | None = None
89
+ self._json: Any = None
90
+ self._json_parsed = False
91
+
92
+ @property
93
+ def text(self) -> str:
94
+ """Decoded response body string."""
95
+ if self._text is None:
96
+ self._text = self.body.decode("utf-8", errors="replace")
97
+ return self._text
98
+
99
+ def json(self) -> Any:
100
+ """Parse JSON response body."""
101
+ if not self._json_parsed:
102
+ if not self.body:
103
+ self._json = None
104
+ else:
105
+ self._json = json.loads(self.text)
106
+ self._json_parsed = True
107
+ return self._json
108
+
109
+ def assert_status(self, expected_status: int) -> TestResponse:
110
+ """Assert that the response status code matches expected status."""
111
+ if self.status_code != expected_status:
112
+ raise AssertionError(
113
+ f"Expected status {expected_status}, but received {self.status_code}. Response: {self.text}"
114
+ )
115
+ return self
116
+
117
+ def assert_ok(self) -> TestResponse:
118
+ """Assert status code is 200 OK."""
119
+ return self.assert_status(200)
120
+
121
+ def assert_created(self) -> TestResponse:
122
+ """Assert status code is 201 Created."""
123
+ return self.assert_status(201)
124
+
125
+ def assert_no_content(self) -> TestResponse:
126
+ """Assert status code is 204 No Content."""
127
+ return self.assert_status(204)
128
+
129
+ def assert_bad_request(self) -> TestResponse:
130
+ """Assert status code is 400 Bad Request."""
131
+ return self.assert_status(400)
132
+
133
+ def assert_unauthorized(self) -> TestResponse:
134
+ """Assert status code is 401 Unauthorized."""
135
+ return self.assert_status(401)
136
+
137
+ def assert_forbidden(self) -> TestResponse:
138
+ """Assert status code is 403 Forbidden."""
139
+ return self.assert_status(403)
140
+
141
+ def assert_not_found(self) -> TestResponse:
142
+ """Assert status code is 404 Not Found."""
143
+ return self.assert_status(404)
144
+
145
+ def assert_method_not_allowed(self) -> TestResponse:
146
+ """Assert status code is 405 Method Not Allowed."""
147
+ return self.assert_status(405)
148
+
149
+ def assert_validation_error(self, field: str | None = None) -> TestResponse:
150
+ """Assert status code is 422 Unprocessable Entity, optionally checking for a field error."""
151
+ self.assert_status(422)
152
+ if field is not None:
153
+ data = self.json()
154
+ if not isinstance(data, dict) or "errors" not in data:
155
+ raise AssertionError(f"Expected structured field errors, got: {data}")
156
+ errors = data["errors"]
157
+ if field not in errors:
158
+ raise AssertionError(
159
+ f"Expected validation error for field '{field}', but errors were: {errors}"
160
+ )
161
+ return self
162
+
163
+ def assert_server_error(self) -> TestResponse:
164
+ """Assert status code is 500 Internal Server Error."""
165
+ return self.assert_status(500)
166
+
167
+ def assert_json(self, expected: Any) -> TestResponse:
168
+ """Assert exact JSON equality."""
169
+ actual = self.json()
170
+ if actual != expected:
171
+ raise AssertionError(f"Expected JSON {expected}, but got {actual}")
172
+ return self
173
+
174
+ def assert_json_fragment(self, fragment: dict[str, Any]) -> TestResponse:
175
+ """Assert that fragment key-values exist in the response JSON."""
176
+ actual = self.json()
177
+ if not isinstance(actual, dict):
178
+ raise AssertionError( # noqa: TRY004
179
+ f"Expected JSON object, got {type(actual)}"
180
+ )
181
+
182
+ for k, v in fragment.items():
183
+ if k not in actual or actual[k] != v:
184
+ raise AssertionError(
185
+ f"Fragment mismatch: expected '{k}': {v}, but actual had '{k}': {actual.get(k)}. Full: {actual}"
186
+ )
187
+ return self
188
+
189
+ def assert_header(
190
+ self, header: str, expected_val: str | None = None
191
+ ) -> TestResponse:
192
+ """Assert header presence and optionally its exact value across all values."""
193
+ values = self.headers.getlist(header)
194
+ if not values:
195
+ raise AssertionError(f"Expected header '{header}' was not found.")
196
+ if expected_val is not None and expected_val not in values:
197
+ if len(values) == 1:
198
+ raise AssertionError(
199
+ f"Expected header '{header}' to be '{expected_val}', got '{values[0]}'."
200
+ )
201
+ raise AssertionError(
202
+ f"Expected header '{header}' to include '{expected_val}', got {values!r}."
203
+ )
204
+ return self
205
+
206
+
207
+ class TestClient:
208
+ """
209
+ In-process ASGI test client.
210
+ Executes HTTP requests directly through the application's ASGI interface
211
+ without binding to network ports or running an external server.
212
+ """
213
+
214
+ __test__ = False
215
+
216
+ def __init__(
217
+ self,
218
+ app: Any,
219
+ base_url: str = "http://localhost",
220
+ default_headers: dict[str, str] | None = None,
221
+ ) -> None:
222
+ self.app = app
223
+ self.base_url = base_url.rstrip("/")
224
+ self._default_headers: dict[str, str] = dict(default_headers or {})
225
+ self._lifespan_to_app: asyncio.Queue[dict[str, Any]] | None = None
226
+ self._lifespan_from_app: asyncio.Queue[dict[str, Any]] | None = None
227
+ self._lifespan_task: asyncio.Task[None] | None = None
228
+ self._lifespan_started = False
229
+
230
+ async def startup(self) -> None:
231
+ """Run the ASGI lifespan startup sequence against the application."""
232
+ if self._lifespan_started:
233
+ return
234
+
235
+ to_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
236
+ from_app: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
237
+ self._lifespan_to_app = to_app
238
+ self._lifespan_from_app = from_app
239
+
240
+ async def receive() -> dict[str, Any]:
241
+ return await to_app.get()
242
+
243
+ async def send(message: dict[str, Any]) -> None:
244
+ await from_app.put(message)
245
+
246
+ scope: dict[str, Any] = {
247
+ "type": "lifespan",
248
+ "asgi": {"version": "3.0", "spec_version": "2.0"},
249
+ }
250
+ task = asyncio.create_task(self.app(scope, receive, send))
251
+
252
+ def _on_done(t: asyncio.Task[None]) -> None:
253
+ if not t.cancelled() and t.exception() is not None:
254
+ logger.debug(
255
+ "Lifespan task ended with exception", exc_info=t.exception()
256
+ )
257
+
258
+ task.add_done_callback(_on_done)
259
+ self._lifespan_task = task
260
+
261
+ await to_app.put({"type": "lifespan.startup"})
262
+ msg = await asyncio.wait_for(from_app.get(), timeout=15.0)
263
+ if msg.get("type") == "lifespan.startup.failed":
264
+ await asyncio.wait_for(task, timeout=5.0)
265
+ self._lifespan_task = None
266
+ raise RuntimeError(msg.get("message") or "Lifespan startup failed")
267
+ if msg.get("type") != "lifespan.startup.complete":
268
+ raise RuntimeError(f"Unexpected lifespan message: {msg}")
269
+ self._lifespan_started = True
270
+
271
+ async def shutdown(self) -> None:
272
+ """Run the ASGI lifespan shutdown sequence against the application."""
273
+ if not self._lifespan_started or self._lifespan_to_app is None or self._lifespan_from_app is None:
274
+ return
275
+
276
+ to_app = self._lifespan_to_app
277
+ from_app = self._lifespan_from_app
278
+ task = self._lifespan_task
279
+ self._lifespan_started = False
280
+ self._lifespan_to_app = None
281
+ self._lifespan_from_app = None
282
+ self._lifespan_task = None
283
+
284
+ await to_app.put({"type": "lifespan.shutdown"})
285
+ msg = await asyncio.wait_for(from_app.get(), timeout=15.0)
286
+ if task is not None and not task.done():
287
+ await asyncio.wait_for(task, timeout=5.0)
288
+ if msg.get("type") == "lifespan.shutdown.failed":
289
+ raise RuntimeError(msg.get("message") or "Lifespan shutdown failed")
290
+
291
+ async def __aenter__(self) -> Self:
292
+ await self.startup()
293
+ return self
294
+
295
+ async def __aexit__(
296
+ self,
297
+ exc_type: type[BaseException] | None,
298
+ exc_val: BaseException | None,
299
+ exc_tb: TracebackType | None,
300
+ ) -> None:
301
+ await self.shutdown()
302
+
303
+ def with_headers(self, headers: dict[str, str]) -> TestClient:
304
+ """Return a new TestClient with additional default headers."""
305
+ merged = _merge_headers_case_insensitive(self._default_headers, headers)
306
+ return TestClient(self.app, base_url=self.base_url, default_headers=merged)
307
+
308
+ def with_token(self, token: str) -> TestClient:
309
+ """Return a new TestClient with a Bearer Authorization header."""
310
+ return self.with_headers({"Authorization": f"Bearer {token}"})
311
+
312
+ def with_api_key(self, key: str, header: str = "X-API-Key") -> TestClient:
313
+ """Return a new TestClient with an API key header."""
314
+ return self.with_headers({header: key})
315
+
316
+ async def request(
317
+ self,
318
+ method: str,
319
+ path: str,
320
+ *,
321
+ json_data: Any = None,
322
+ data: bytes | str | Mapping[str, Any] | None = None,
323
+ headers: dict[str, str] | None = None,
324
+ query: dict[str, Any] | None = None,
325
+ ) -> TestResponse:
326
+ """Execute an in-process HTTP request against the ASGI application."""
327
+ req_headers = _merge_headers_case_insensitive(self._default_headers, headers)
328
+ lower_keys = {k.lower() for k in req_headers}
329
+
330
+ parsed_base = urlsplit(self.base_url)
331
+ if "host" not in lower_keys:
332
+ req_headers["host"] = parsed_base.netloc or "localhost"
333
+ lower_keys.add("host")
334
+
335
+ # Prepare body
336
+ body_bytes = b""
337
+ if json_data is not None:
338
+ body_bytes = json.dumps(json_data).encode("utf-8")
339
+ if "content-type" not in lower_keys:
340
+ req_headers["content-type"] = "application/json"
341
+ elif isinstance(data, str):
342
+ body_bytes = data.encode("utf-8")
343
+ elif isinstance(data, bytes):
344
+ body_bytes = data
345
+ elif isinstance(data, Mapping):
346
+ body_bytes = urlencode(
347
+ list(data.items()), doseq=True, encoding="utf-8"
348
+ ).encode("ascii")
349
+ if "content-type" not in lower_keys:
350
+ req_headers["content-type"] = "application/x-www-form-urlencoded"
351
+ elif data is not None:
352
+ raise TypeError(
353
+ f"Unsupported request data type: {type(data).__name__}. "
354
+ "Expected bytes, str, or Mapping."
355
+ )
356
+
357
+ if body_bytes and "content-length" not in {k.lower() for k in req_headers}:
358
+ req_headers["content-length"] = str(len(body_bytes))
359
+
360
+ decoded_path, raw_path_bytes, query_string = _build_path_and_query(path, query)
361
+
362
+ raw_headers = [
363
+ (k.lower().encode("latin-1"), v.encode("latin-1"))
364
+ for k, v in req_headers.items()
365
+ ]
366
+
367
+ scheme = parsed_base.scheme or "http"
368
+ default_port = 443 if scheme == "https" else 80
369
+ scope = {
370
+ "type": "http",
371
+ "asgi": {"version": "3.0"},
372
+ "http_version": "1.1",
373
+ "method": method.upper(),
374
+ "path": decoded_path,
375
+ "raw_path": raw_path_bytes,
376
+ "query_string": query_string,
377
+ "headers": raw_headers,
378
+ "server": (parsed_base.hostname or "127.0.0.1", parsed_base.port or default_port),
379
+ "client": ("127.0.0.1", 50000),
380
+ "scheme": scheme,
381
+ }
382
+
383
+ status_code = 0
384
+ resp_headers = ResponseHeaders()
385
+ resp_body = bytearray()
386
+ body_sent = False
387
+
388
+ async def receive() -> dict[str, Any]:
389
+ nonlocal body_sent
390
+ if not body_sent:
391
+ body_sent = True
392
+ return {
393
+ "type": "http.request",
394
+ "body": body_bytes,
395
+ "more_body": False,
396
+ }
397
+ return {"type": "http.disconnect"}
398
+
399
+ async def send(message: dict[str, Any]) -> None:
400
+ nonlocal status_code, resp_headers, resp_body
401
+ if message["type"] == "http.response.start":
402
+ status_code = message["status"]
403
+ for k, v in message.get("headers", []):
404
+ h_key = k.decode("latin-1")
405
+ h_val = v.decode("latin-1")
406
+ resp_headers.add(h_key, h_val)
407
+ elif message["type"] == "http.response.body":
408
+ resp_body.extend(message.get("body", b""))
409
+
410
+ await self.app(scope, receive, send)
411
+
412
+ return TestResponse(
413
+ status_code=status_code,
414
+ headers=resp_headers,
415
+ body=bytes(resp_body),
416
+ )
417
+
418
+ async def get(
419
+ self,
420
+ path: str,
421
+ *,
422
+ headers: dict[str, str] | None = None,
423
+ query: dict[str, Any] | None = None,
424
+ ) -> TestResponse:
425
+ return await self.request("GET", path, headers=headers, query=query)
426
+
427
+ async def post(
428
+ self,
429
+ path: str,
430
+ *,
431
+ json: Any = None,
432
+ data: bytes | str | Mapping[str, Any] | None = None,
433
+ headers: dict[str, str] | None = None,
434
+ query: dict[str, Any] | None = None,
435
+ ) -> TestResponse:
436
+ return await self.request(
437
+ "POST",
438
+ path,
439
+ json_data=json,
440
+ data=data,
441
+ headers=headers,
442
+ query=query,
443
+ )
444
+
445
+ async def put(
446
+ self,
447
+ path: str,
448
+ *,
449
+ json: Any = None,
450
+ data: bytes | str | Mapping[str, Any] | None = None,
451
+ headers: dict[str, str] | None = None,
452
+ query: dict[str, Any] | None = None,
453
+ ) -> TestResponse:
454
+ return await self.request(
455
+ "PUT", path, json_data=json, data=data, headers=headers, query=query
456
+ )
457
+
458
+ async def patch(
459
+ self,
460
+ path: str,
461
+ *,
462
+ json: Any = None,
463
+ data: bytes | str | Mapping[str, Any] | None = None,
464
+ headers: dict[str, str] | None = None,
465
+ query: dict[str, Any] | None = None,
466
+ ) -> TestResponse:
467
+ return await self.request(
468
+ "PATCH",
469
+ path,
470
+ json_data=json,
471
+ data=data,
472
+ headers=headers,
473
+ query=query,
474
+ )
475
+
476
+ async def delete(
477
+ self,
478
+ path: str,
479
+ *,
480
+ json: Any = None,
481
+ data: bytes | str | Mapping[str, Any] | None = None,
482
+ headers: dict[str, str] | None = None,
483
+ query: dict[str, Any] | None = None,
484
+ ) -> TestResponse:
485
+ return await self.request(
486
+ "DELETE",
487
+ path,
488
+ json_data=json,
489
+ data=data,
490
+ headers=headers,
491
+ query=query,
492
+ )
493
+
494
+ async def head(
495
+ self,
496
+ path: str,
497
+ *,
498
+ headers: dict[str, str] | None = None,
499
+ query: dict[str, Any] | None = None,
500
+ ) -> TestResponse:
501
+ return await self.request("HEAD", path, headers=headers, query=query)
502
+
503
+ async def options(
504
+ self,
505
+ path: str,
506
+ *,
507
+ headers: dict[str, str] | None = None,
508
+ query: dict[str, Any] | None = None,
509
+ ) -> TestResponse:
510
+ return await self.request("OPTIONS", path, headers=headers, query=query)
511
+
512
+ def websocket_connect(
513
+ self,
514
+ path: str,
515
+ *,
516
+ headers: dict[str, str] | None = None,
517
+ subprotocols: list[str] | None = None,
518
+ ) -> WebSocketConnectCM:
519
+ """Establish an in-process WebSocket connection."""
520
+ return WebSocketConnectCM(
521
+ self, path, headers=headers, subprotocols=subprotocols
522
+ )
523
+
524
+
525
+ class WebSocketTestSession:
526
+ """
527
+ In-process WebSocket test session for testing ASGI WebSocket endpoints.
528
+ """
529
+
530
+ __test__ = False
531
+
532
+ def __init__(
533
+ self,
534
+ app: Any,
535
+ default_headers: dict[str, str] | None = None,
536
+ ) -> None:
537
+ self.app = app
538
+ self.default_headers = default_headers or {}
539
+ self._to_app_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
540
+ self._from_app_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
541
+ self._app_task: asyncio.Task[None] | None = None
542
+ self.is_closed = False
543
+ self.accepted_subprotocol: str | None = None
544
+ self.accepted_headers: list[tuple[bytes, bytes]] = []
545
+
546
+ async def connect(
547
+ self,
548
+ path: str,
549
+ *,
550
+ headers: dict[str, str] | None = None,
551
+ subprotocols: list[str] | None = None,
552
+ ) -> None:
553
+ decoded_path, raw_path_bytes, query_string = _build_path_and_query(path)
554
+
555
+ req_headers = _merge_headers_case_insensitive(self.default_headers, headers)
556
+ if "host" not in {k.lower() for k in req_headers}:
557
+ req_headers["host"] = "127.0.0.1:80"
558
+
559
+ raw_headers = [
560
+ (k.lower().encode("latin-1"), v.encode("latin-1"))
561
+ for k, v in req_headers.items()
562
+ ]
563
+
564
+ scope: dict[str, Any] = {
565
+ "type": "websocket",
566
+ "asgi": {"version": "3.0"},
567
+ "http_version": "1.1",
568
+ "scheme": "ws",
569
+ "path": decoded_path,
570
+ "raw_path": raw_path_bytes,
571
+ "query_string": query_string,
572
+ "headers": raw_headers,
573
+ "subprotocols": subprotocols or [],
574
+ "client": ("127.0.0.1", 50000),
575
+ "server": ("127.0.0.1", 80),
576
+ }
577
+
578
+ await self._to_app_queue.put({"type": "websocket.connect"})
579
+
580
+ async def receive() -> dict[str, Any]:
581
+ return await self._to_app_queue.get()
582
+
583
+ async def send(message: dict[str, Any]) -> None:
584
+ await self._from_app_queue.put(message)
585
+
586
+ self._app_task = asyncio.create_task(self.app(scope, receive, send))
587
+
588
+ def _on_done(t: asyncio.Task[None]) -> None:
589
+ if not t.cancelled() and t.exception() is not None:
590
+ logger.debug("WebSocket app task ended with exception", exc_info=t.exception())
591
+
592
+ self._app_task.add_done_callback(_on_done)
593
+
594
+ try:
595
+ msg = await asyncio.wait_for(self._from_app_queue.get(), timeout=5.0)
596
+ except TimeoutError:
597
+ self.is_closed = True
598
+ if self._app_task and not self._app_task.done():
599
+ self._app_task.cancel()
600
+ raise TimeoutError("Timed out waiting for WebSocket handshake response") from None
601
+
602
+ if msg["type"] == "websocket.close":
603
+ self.is_closed = True
604
+ code = msg.get("code", 1000)
605
+ reason = msg.get("reason", "")
606
+ raise WebSocketDisconnect(code=code, reason=reason)
607
+
608
+ if msg["type"] == "websocket.accept":
609
+ self.accepted_subprotocol = msg.get("subprotocol")
610
+ self.accepted_headers = msg.get("headers", [])
611
+
612
+ async def send_text(self, text: str) -> None:
613
+ if self.is_closed:
614
+ raise WebSocketDisconnect(1000, "Connection already closed")
615
+ await self._to_app_queue.put({"type": "websocket.receive", "text": text})
616
+
617
+ async def send_bytes(self, data: bytes) -> None:
618
+ if self.is_closed:
619
+ raise WebSocketDisconnect(1000, "Connection already closed")
620
+ await self._to_app_queue.put({"type": "websocket.receive", "bytes": data})
621
+
622
+ async def send_json(self, data: Any) -> None:
623
+ await self.send_text(json.dumps(data))
624
+
625
+ async def receive_raw(self, timeout: float = 5.0) -> dict[str, Any]:
626
+ if self.is_closed:
627
+ raise WebSocketDisconnect(1000, "Connection already closed")
628
+ try:
629
+ msg = await asyncio.wait_for(self._from_app_queue.get(), timeout=timeout)
630
+ except TimeoutError:
631
+ raise TimeoutError("Timed out waiting for message from WebSocket") from None
632
+
633
+ if msg["type"] == "websocket.close":
634
+ self.is_closed = True
635
+ code = msg.get("code", 1000)
636
+ reason = msg.get("reason", "")
637
+ raise WebSocketDisconnect(code=code, reason=reason)
638
+
639
+ return msg
640
+
641
+ async def receive_text(self, timeout: float = 5.0) -> str:
642
+ msg = await self.receive_raw(timeout=timeout)
643
+ if "text" in msg and msg["text"] is not None:
644
+ return str(msg["text"])
645
+ if "bytes" in msg and msg["bytes"] is not None:
646
+ return bytes(msg["bytes"]).decode("utf-8")
647
+ return ""
648
+
649
+ async def receive_bytes(self, timeout: float = 5.0) -> bytes:
650
+ msg = await self.receive_raw(timeout=timeout)
651
+ if "bytes" in msg and msg["bytes"] is not None:
652
+ return bytes(msg["bytes"])
653
+ if "text" in msg and msg["text"] is not None:
654
+ return str(msg["text"]).encode("utf-8")
655
+ return b""
656
+
657
+ async def receive_json(self, timeout: float = 5.0) -> Any:
658
+ text = await self.receive_text(timeout=timeout)
659
+ return json.loads(text)
660
+
661
+ async def close(self, code: int = 1000, reason: str = "") -> None:
662
+ if self.is_closed:
663
+ return
664
+ self.is_closed = True
665
+ await self._to_app_queue.put(
666
+ {"type": "websocket.disconnect", "code": code, "reason": reason}
667
+ )
668
+ if self._app_task and not self._app_task.done():
669
+ try:
670
+ await asyncio.wait_for(asyncio.shield(self._app_task), timeout=2.0)
671
+ except (TimeoutError, asyncio.CancelledError):
672
+ self._app_task.cancel()
673
+
674
+
675
+ class WebSocketConnectCM:
676
+ """Async context manager and awaitable for connecting to an ASGI WebSocket endpoint."""
677
+
678
+ __test__ = False
679
+
680
+ def __init__(
681
+ self,
682
+ client: TestClient,
683
+ path: str,
684
+ headers: dict[str, str] | None = None,
685
+ subprotocols: list[str] | None = None,
686
+ ) -> None:
687
+ self.client = client
688
+ self.path = path
689
+ self.headers = headers
690
+ self.subprotocols = subprotocols
691
+ self.session: WebSocketTestSession | None = None
692
+
693
+ async def __aenter__(self) -> WebSocketTestSession:
694
+ session = WebSocketTestSession(
695
+ self.client.app, default_headers=self.client._default_headers
696
+ )
697
+ await session.connect(
698
+ self.path, headers=self.headers, subprotocols=self.subprotocols
699
+ )
700
+ self.session = session
701
+ return session
702
+
703
+ async def __aexit__(
704
+ self,
705
+ exc_type: type[BaseException] | None,
706
+ exc_val: BaseException | None,
707
+ exc_tb: TracebackType | None,
708
+ ) -> None:
709
+ if self.session is not None:
710
+ await self.session.close()
711
+
712
+ def __await__(self) -> Any:
713
+ async def _connect() -> WebSocketTestSession:
714
+ session = WebSocketTestSession(
715
+ self.client.app, default_headers=self.client._default_headers
716
+ )
717
+ await session.connect(
718
+ self.path, headers=self.headers, subprotocols=self.subprotocols
719
+ )
720
+ return session
721
+
722
+ return _connect().__await__()
723
+
724
+
725
+ __all__ = [
726
+ "TestClient",
727
+ "TestResponse",
728
+ "WebSocketConnectCM",
729
+ "WebSocketTestSession",
730
+ ]