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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|