quadkit-http 0.0.43__tar.gz → 0.0.45__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 (79) hide show
  1. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/PKG-INFO +1 -1
  2. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/pyproject.toml +1 -1
  3. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/__init__.py +6 -1
  4. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/cli/templates/api_client.py.jinja2 +41 -26
  5. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/client/__init__.py +7 -1
  6. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/websocket.py +9 -3
  7. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/cli/test_api_client_generator.py +13 -0
  8. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_client_verbs.py +2 -2
  9. quadkit_http-0.0.45/tests/unit/test_websocket.py +253 -0
  10. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/.gitignore +0 -0
  11. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/LICENSE +0 -0
  12. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/NOTICE +0 -0
  13. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/README.md +0 -0
  14. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/example.yaml +0 -0
  15. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/cli/__init__.py +0 -0
  16. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/cli/contributor.py +0 -0
  17. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/cli/generators/__init__.py +0 -0
  18. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/cli/generators/api_client.py +0 -0
  19. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/client/_streaming.py +0 -0
  20. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/client/_verbs.py +0 -0
  21. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/client/base_url_client.py +0 -0
  22. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/client/http_client.py +0 -0
  23. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/config.py +0 -0
  24. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/constants.py +0 -0
  25. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/decorators.py +0 -0
  26. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/di/__init__.py +0 -0
  27. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/di/provider.py +0 -0
  28. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/events.py +0 -0
  29. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/exceptions.py +0 -0
  30. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/hooks.py +0 -0
  31. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/lib/__init__.py +0 -0
  32. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/lib/format.py +0 -0
  33. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/lib/headers.py +0 -0
  34. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/lib/url.py +0 -0
  35. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/module.py +0 -0
  36. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/pool/__init__.py +0 -0
  37. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/pool/connection_pool.py +0 -0
  38. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/protocols.py +0 -0
  39. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/py.typed +0 -0
  40. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/retry/__init__.py +0 -0
  41. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/retry/policy.py +0 -0
  42. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/types.py +0 -0
  43. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/validation/__init__.py +0 -0
  44. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/validation/primitives.py +0 -0
  45. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/http/validation/url.py +0 -0
  46. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/src/quadkit/py.typed +0 -0
  47. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/conftest.py +0 -0
  48. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/integration/__init__.py +0 -0
  49. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/integration/test_http_lifecycle.py +0 -0
  50. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/cli/test_generators_contract.py +0 -0
  51. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/http_module_test_support.py +0 -0
  52. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_base_url_client_streaming_and_events.py +0 -0
  53. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_exceptions.py +0 -0
  54. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_client.py +0 -0
  55. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_constants.py +0 -0
  56. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_exceptions.py +0 -0
  57. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_module_config.py +0 -0
  58. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_module_interceptors.py +0 -0
  59. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_module_validation.py +0 -0
  60. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_providers.py +0 -0
  61. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_streaming.py +0 -0
  62. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_types.py +0 -0
  63. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_types_extended.py +0 -0
  64. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_http_url_safety.py +0 -0
  65. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_lib_format.py +0 -0
  66. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_lib_headers.py +0 -0
  67. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_m17_m18_http.py +0 -0
  68. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_package_hooks_p2.py +0 -0
  69. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_package_structure_p1.py +0 -0
  70. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_protocols.py +0 -0
  71. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_redirect_ssrf.py +0 -0
  72. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_retry_policy.py +0 -0
  73. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_retry_policy_canonical.py +0 -0
  74. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_round24_hardening.py +0 -0
  75. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_url_utils.py +0 -0
  76. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_utils.py +0 -0
  77. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_validation.py +0 -0
  78. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_validation_primitives.py +0 -0
  79. {quadkit_http-0.0.43 → quadkit_http-0.0.45}/tests/unit/test_validation_url.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: quadkit-http
3
- Version: 0.0.43
3
+ Version: 0.0.45
4
4
  Summary: Outbound HTTP client for the Quadkit Framework
5
5
  Project-URL: Homepage, https://dbtinoy-.github.io/quadkit
6
6
  Project-URL: Repository, https://github.com/dbtinoy-/quadkit
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quadkit-http"
7
- version = "0.0.43"
7
+ version = "0.0.45"
8
8
  description = "Outbound HTTP client for the Quadkit Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -35,7 +35,12 @@ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
35
35
  from quadkit.http.constants import __version__ as __version__
36
36
 
37
37
  if TYPE_CHECKING:
38
- from quadkit.http.client import BaseURLHTTPClient, HTTPClient, StreamContext, StreamResponse
38
+ from quadkit.http.client import (
39
+ BaseURLHTTPClient,
40
+ HTTPClient,
41
+ StreamContext,
42
+ StreamResponse,
43
+ )
39
44
  from quadkit.http.config import (
40
45
  ConnectionPoolConfig,
41
46
  HTTPClientConfig,
@@ -1,6 +1,14 @@
1
1
  """{{ client_name }} API client.
2
2
 
3
- This module provides a client for interacting with the {{ client_name }} API.
3
+ Outbound boundary for the {{ client_name }} API: base URL, auth,
4
+ pooling and retries stay inside; callers see framework-owned
5
+ ``HttpResponse`` objects, never the transport library.
6
+
7
+ Convention: **Result + ProblemDetail** — ``_request`` answers
8
+ ``Result[HttpResponse, {{ client_name }}APIError]`` so a failing outbound
9
+ call is a VALUE the caller maps (.map_sync / .map_err), not an exception it
10
+ must catch. A request that cannot complete once becomes one ``Err`` —
11
+ the caller's own Err arm (R6: absence is a value).
4
12
  """
5
13
 
6
14
  from __future__ import annotations
@@ -10,6 +18,7 @@ from typing import Any
10
18
  from quadkit.contracts.web import HttpResponse
11
19
  from quadkit.http import BaseURLHTTPClient
12
20
  from quadkit.logging import get_logger
21
+ from quadkit.result import Err, Ok, Result
13
22
 
14
23
  logger = get_logger(__name__)
15
24
 
@@ -70,10 +79,10 @@ class {{ client_name }}Client:
70
79
  # =========================================================================
71
80
  # Add your API methods here. Examples:
72
81
  #
73
- # async def get_user(self, user_id: str) -> dict[str, Any]:
82
+ # async def get_user(self, user_id: str) -> Result[dict[str, Any], {{ client_name }}APIError]:
74
83
  # """Get a user by ID."""
75
- # response = await self._request("GET", f"/users/{user_id}")
76
- # return response.json or {}
84
+ # result = await self._request("GET", f"/users/{user_id}")
85
+ # return result.map_sync(lambda response: response.json or {})
77
86
  #
78
87
  # async def list_users(self, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]:
79
88
  # """List users with pagination."""
@@ -82,10 +91,10 @@ class {{ client_name }}Client:
82
91
  # )
83
92
  # return response.json or []
84
93
  #
85
- # async def create_user(self, data: dict[str, Any]) -> dict[str, Any]:
94
+ # async def create_user(self, data: dict[str, Any]) -> Result[dict[str, Any], {{ client_name }}APIError]:
86
95
  # """Create a new user."""
87
- # response = await self._request("POST", "/users", json=data)
88
- # return response.json or {}
96
+ # result = await self._request("POST", "/users", json=data)
97
+ # return result.map_sync(lambda response: response.json or {})
89
98
  #
90
99
  # async def update_user(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
91
100
  # """Update an existing user."""
@@ -105,8 +114,8 @@ class {{ client_name }}Client:
105
114
  method: str,
106
115
  endpoint: str,
107
116
  **kwargs: Any,
108
- ) -> HttpResponse:
109
- """Make an HTTP request with error handling.
117
+ ) -> Result[HttpResponse, {{ client_name }}APIError]:
118
+ """Make an HTTP request, answering the outcome as one Result.
110
119
 
111
120
  Args:
112
121
  method: HTTP method (GET, POST, PUT, PATCH, DELETE).
@@ -114,33 +123,39 @@ class {{ client_name }}Client:
114
123
  **kwargs: Additional arguments forwarded to the HTTP client.
115
124
 
116
125
  Returns:
117
- The framework HTTP response.
118
-
119
- Raises:
120
- {{ client_name }}APIError: If the API returns an error status.
126
+ ``Ok(response)`` on a passing status, ``Err(APIError)`` on a
127
+ client/server failure or a transport exception — never raised,
128
+ so consumers compose with .map_sync / .map_err instead of try/except.
121
129
  """
122
130
  try:
123
131
  response = await self._client.request(method, endpoint, **kwargs)
124
- if response.status >= 400:
125
- raise {{ client_name }}APIError(
132
+ except Exception as exc: # noqa: BLE001
133
+ logger.exception("{{ client_name_snake }}_request_error", method=method)
134
+ return Err(
135
+ {{ client_name }}APIError(
136
+ message=f"Request failed: {exc}",
137
+ status_code=None,
138
+ response=None,
139
+ )
140
+ )
141
+ if response.status >= 400:
142
+ return Err(
143
+ {{ client_name }}APIError(
126
144
  message=f"API request failed: {response.status}",
127
145
  status_code=response.status,
128
146
  response=response,
129
147
  )
130
- return response
131
- except {{ client_name }}APIError:
132
- raise
133
- except Exception as exc: # noqa: BLE001
134
- logger.exception("{{ client_name_snake }}_request_error", method=method)
135
- raise {{ client_name }}APIError(
136
- message=f"Request failed: {exc}",
137
- status_code=None,
138
- response=None,
139
- ) from exc
148
+ )
149
+ return Ok(response)
140
150
 
141
151
 
142
152
  class {{ client_name }}APIError(Exception):
143
- """Exception raised when the {{ client_name }} API returns an error."""
153
+ """The {{ client_name }} API failure outcome as a value.
154
+
155
+ Carried in ``Err`` (R6) — callers compose instead of try/except. It
156
+ subclasses ``Exception`` only so a consumer MAY escalate it across an
157
+ outer infrastructure boundary unchanged.
158
+ """
144
159
 
145
160
  def __init__(
146
161
  self,
@@ -7,4 +7,10 @@ from quadkit.http.client.base_url_client import BaseURLHTTPClient, StreamContext
7
7
  from quadkit.http.client.http_client import HTTPClient
8
8
  from quadkit.http.pool import ConnectionPool
9
9
 
10
- __all__ = ["BaseURLHTTPClient", "ConnectionPool", "HTTPClient", "StreamContext", "StreamResponse"]
10
+ __all__ = [
11
+ "BaseURLHTTPClient",
12
+ "ConnectionPool",
13
+ "HTTPClient",
14
+ "StreamContext",
15
+ "StreamResponse",
16
+ ]
@@ -9,7 +9,8 @@ client. HTTP applications use the same aiohttp connection stack as the rest of
9
9
  from __future__ import annotations
10
10
 
11
11
  from dataclasses import dataclass
12
- from typing import Any
12
+ from types import TracebackType
13
+ from typing import Any, Self
13
14
 
14
15
  import aiohttp
15
16
 
@@ -42,7 +43,7 @@ class WebSocketClient:
42
43
  self._session: aiohttp.ClientSession | None = None
43
44
  self._socket: aiohttp.ClientWebSocketResponse | None = None
44
45
 
45
- async def __aenter__(self) -> WebSocketClient:
46
+ async def __aenter__(self) -> Self:
46
47
  self._session = aiohttp.ClientSession(
47
48
  timeout=aiohttp.ClientTimeout(total=None, sock_connect=self.connect_timeout)
48
49
  )
@@ -59,7 +60,12 @@ class WebSocketClient:
59
60
  raise
60
61
  return self
61
62
 
62
- async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
63
+ async def __aexit__(
64
+ self,
65
+ exc_type: type[BaseException] | None,
66
+ exc: BaseException | None,
67
+ tb: TracebackType | None,
68
+ ) -> None:
63
69
  await self.close()
64
70
 
65
71
  @property
@@ -41,6 +41,19 @@ class TestAPIClientGenerator:
41
41
  assert "token" in content
42
42
  assert "PaymentsClient" in content
43
43
 
44
+ def test_request_answers_result_never_raises(self, tmp_path) -> None:
45
+ g = _gen(tmp_path)
46
+ g.generate("Stripe")
47
+ content = (tmp_path / "clients" / "stripe_client.py").read_text()
48
+ # the _request seam is Result-shaped: an outbound failure is a VALUE
49
+ assert "from quadkit.result import Err, Ok, Result" in content
50
+ assert "-> Result[HttpResponse, StripeAPIError]:" in content
51
+ assert "return Ok(response)" in content
52
+ assert content.count("return Err(") == 2
53
+ assert "raise StripeAPIError(" not in content
54
+ # scaffold examples teach composing, not catching
55
+ assert "return result.map_sync(lambda response:" in content
56
+
44
57
  def test_fields_str_invalid_raises(self, tmp_path) -> None:
45
58
  import pytest
46
59
 
@@ -278,9 +278,9 @@ class TestTimeoutNormalization:
278
278
  async def test_get_normalises_aiohttp_connection_error(self):
279
279
  """aiohttp.ClientError (connect refused/DNS/reset) → Err(HTTPConnectionError).
280
280
 
281
- Regression (QK-USE-3, gatewai probe adoption): raw aiohttp errors used
281
+ Detail (QK-USE-3, probe adoption): raw aiohttp errors used
282
282
  to escape `get()` instead of mapping to the documented connection-error
283
- Result — unreachable hosts crashed callers that expected `Err`.
283
+ Result — unreachable hosts reach callers that expected `Err`.
284
284
  """
285
285
  client = HTTPClientImpl(
286
286
  config=HTTPClientConfig(enforce_url_safety=False),
@@ -0,0 +1,253 @@
1
+ """Unit tests for the outbound WebSocket transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import FrozenInstanceError
6
+ from types import SimpleNamespace
7
+ from typing import Any
8
+ from unittest.mock import AsyncMock, MagicMock, patch
9
+
10
+ import aiohttp
11
+ import pytest
12
+
13
+ from quadkit.http.websocket import WebSocketClient, WebSocketMessage
14
+
15
+
16
+ def _frame(msg_type: aiohttp.WSMsgType, data: Any = None) -> SimpleNamespace:
17
+ """A stand-in for aiohttp's WSMessage, which is a namedtuple we only read."""
18
+ return SimpleNamespace(type=msg_type, data=data)
19
+
20
+
21
+ def _connected_client(socket: AsyncMock | None = None) -> WebSocketClient:
22
+ """A client whose socket is already attached, bypassing __aenter__."""
23
+ client = WebSocketClient("ws://example.test/socket")
24
+ client._socket = socket if socket is not None else AsyncMock(closed=False)
25
+ return client
26
+
27
+
28
+ class TestWebSocketMessage:
29
+ """The normalized message value object."""
30
+
31
+ def test_defaults(self) -> None:
32
+ message = WebSocketMessage("text")
33
+ assert message.type == "text"
34
+ assert message.data is None
35
+
36
+ def test_is_frozen(self) -> None:
37
+ message = WebSocketMessage("text", "payload")
38
+ with pytest.raises(FrozenInstanceError):
39
+ message.type = "bytes" # type: ignore[misc]
40
+
41
+
42
+ class TestConstruction:
43
+ """Scheme validation and stored configuration."""
44
+
45
+ @pytest.mark.parametrize("url", ["ws://example.test/s", "wss://example.test/s"])
46
+ def test_accepts_websocket_schemes(self, url: str) -> None:
47
+ client = WebSocketClient(url)
48
+ assert client.url == url
49
+
50
+ @pytest.mark.parametrize(
51
+ "url", ["http://example.test", "https://example.test", "example.test"]
52
+ )
53
+ def test_rejects_non_websocket_schemes(self, url: str) -> None:
54
+ with pytest.raises(ValueError, match="must start with ws:// or wss://"):
55
+ WebSocketClient(url)
56
+
57
+ def test_defaults(self) -> None:
58
+ client = WebSocketClient("ws://example.test/s")
59
+ assert client.headers == {}
60
+ assert client.connect_timeout == 30.0
61
+ assert client.heartbeat == 30.0
62
+ assert client._session is None
63
+ assert client._socket is None
64
+
65
+ def test_overrides(self) -> None:
66
+ client = WebSocketClient(
67
+ "wss://example.test/s",
68
+ headers={"Authorization": "Bearer t"},
69
+ connect_timeout=5.0,
70
+ heartbeat=None,
71
+ )
72
+ assert client.headers == {"Authorization": "Bearer t"}
73
+ assert client.connect_timeout == 5.0
74
+ assert client.heartbeat is None
75
+
76
+
77
+ class TestClosedProperty:
78
+ """`closed` reports the upstream socket, not the wrapper."""
79
+
80
+ def test_true_before_connect(self) -> None:
81
+ assert WebSocketClient("ws://example.test/s").closed is True
82
+
83
+ def test_false_while_socket_open(self) -> None:
84
+ assert _connected_client(AsyncMock(closed=False)).closed is False
85
+
86
+ def test_true_once_socket_closed(self) -> None:
87
+ assert _connected_client(AsyncMock(closed=True)).closed is True
88
+
89
+
90
+ class TestSendRequiresConnection:
91
+ """Every send fails fast rather than silently dropping the frame."""
92
+
93
+ async def test_send_json(self) -> None:
94
+ with pytest.raises(RuntimeError, match="not connected"):
95
+ await WebSocketClient("ws://example.test/s").send_json({"a": 1})
96
+
97
+ async def test_send_text(self) -> None:
98
+ with pytest.raises(RuntimeError, match="not connected"):
99
+ await WebSocketClient("ws://example.test/s").send_text("hi")
100
+
101
+ async def test_send_bytes(self) -> None:
102
+ with pytest.raises(RuntimeError, match="not connected"):
103
+ await WebSocketClient("ws://example.test/s").send_bytes(b"\x00")
104
+
105
+ async def test_receive(self) -> None:
106
+ with pytest.raises(RuntimeError, match="not connected"):
107
+ await WebSocketClient("ws://example.test/s").receive()
108
+
109
+
110
+ class TestSendDelegation:
111
+ """Connected sends reach the matching aiohttp call."""
112
+
113
+ async def test_send_json(self) -> None:
114
+ socket = AsyncMock(closed=False)
115
+ await _connected_client(socket).send_json({"a": 1})
116
+ socket.send_json.assert_awaited_once_with({"a": 1})
117
+
118
+ async def test_send_text(self) -> None:
119
+ socket = AsyncMock(closed=False)
120
+ await _connected_client(socket).send_text("hi")
121
+ socket.send_str.assert_awaited_once_with("hi")
122
+
123
+ async def test_send_bytes(self) -> None:
124
+ socket = AsyncMock(closed=False)
125
+ await _connected_client(socket).send_bytes(b"\x01")
126
+ socket.send_bytes.assert_awaited_once_with(b"\x01")
127
+
128
+
129
+ class TestReceiveNormalization:
130
+ """Upstream frame types map onto the transport's own vocabulary."""
131
+
132
+ async def test_text(self) -> None:
133
+ socket = AsyncMock(closed=False)
134
+ socket.receive.return_value = _frame(aiohttp.WSMsgType.TEXT, "hello")
135
+ assert await _connected_client(socket).receive() == WebSocketMessage(
136
+ "text", "hello"
137
+ )
138
+
139
+ async def test_binary(self) -> None:
140
+ socket = AsyncMock(closed=False)
141
+ socket.receive.return_value = _frame(aiohttp.WSMsgType.BINARY, b"\x01\x02")
142
+ assert await _connected_client(socket).receive() == WebSocketMessage(
143
+ "bytes", b"\x01\x02"
144
+ )
145
+
146
+ @pytest.mark.parametrize(
147
+ "msg_type", [aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSED]
148
+ )
149
+ async def test_close_frames(self, msg_type: aiohttp.WSMsgType) -> None:
150
+ socket = AsyncMock(closed=False)
151
+ socket.receive.return_value = _frame(msg_type, None)
152
+ assert await _connected_client(socket).receive() == WebSocketMessage(
153
+ "close", None
154
+ )
155
+
156
+ async def test_error_stringifies_the_exception(self) -> None:
157
+ socket = AsyncMock(closed=False)
158
+ socket.receive.return_value = _frame(
159
+ aiohttp.WSMsgType.ERROR, ValueError("boom")
160
+ )
161
+ message = await _connected_client(socket).receive()
162
+ assert message.type == "error"
163
+ assert message.data == "boom"
164
+
165
+ async def test_control_frames_are_exposed_lowercased(self) -> None:
166
+ """Ping/pong are autoping'd, but callers can still see them."""
167
+ socket = AsyncMock(closed=False)
168
+ socket.receive.return_value = _frame(aiohttp.WSMsgType.PING, b"")
169
+ message = await _connected_client(socket).receive()
170
+ assert message.type == str(aiohttp.WSMsgType.PING).lower()
171
+ assert message.data == b""
172
+
173
+
174
+ class TestConnectionLifecycle:
175
+ """__aenter__ owns the session, including when the handshake fails."""
176
+
177
+ async def test_aenter_connects_and_returns_self(self) -> None:
178
+ socket = AsyncMock(closed=False)
179
+ session = MagicMock()
180
+ session.ws_connect = AsyncMock(return_value=socket)
181
+ session.close = AsyncMock()
182
+
183
+ with patch(
184
+ "quadkit.http.websocket.aiohttp.ClientSession", return_value=session
185
+ ):
186
+ client = WebSocketClient(
187
+ "wss://example.test/s", headers={"X": "1"}, heartbeat=7.0
188
+ )
189
+ entered = await client.__aenter__()
190
+
191
+ assert entered is client
192
+ assert client._session is session
193
+ assert client._socket is socket
194
+ session.ws_connect.assert_awaited_once_with(
195
+ "wss://example.test/s",
196
+ headers={"X": "1"},
197
+ heartbeat=7.0,
198
+ autoping=True,
199
+ )
200
+ session.close.assert_not_awaited()
201
+
202
+ async def test_aenter_closes_the_session_when_the_handshake_fails(self) -> None:
203
+ """A failed connect must not leak the ClientSession it just opened."""
204
+ session = MagicMock()
205
+ session.ws_connect = AsyncMock(side_effect=OSError("refused"))
206
+ session.close = AsyncMock()
207
+
208
+ with patch(
209
+ "quadkit.http.websocket.aiohttp.ClientSession", return_value=session
210
+ ):
211
+ client = WebSocketClient("ws://example.test/s")
212
+ with pytest.raises(OSError, match="refused"):
213
+ await client.__aenter__()
214
+
215
+ session.close.assert_awaited_once()
216
+ assert client._session is None
217
+ assert client.closed is True
218
+
219
+ async def test_aexit_closes(self) -> None:
220
+ client = _connected_client(AsyncMock(closed=False))
221
+ client._session = MagicMock(close=AsyncMock())
222
+ await client.__aexit__(None, None, None)
223
+ assert client._socket is None
224
+ assert client._session is None
225
+
226
+
227
+ class TestClose:
228
+ """close() is safe to call on an already-torn-down client."""
229
+
230
+ async def test_closes_open_socket_and_session(self) -> None:
231
+ socket = AsyncMock(closed=False)
232
+ session = MagicMock(close=AsyncMock())
233
+ client = _connected_client(socket)
234
+ client._session = session
235
+
236
+ await client.close()
237
+
238
+ socket.close.assert_awaited_once()
239
+ session.close.assert_awaited_once()
240
+ assert client._socket is None
241
+ assert client._session is None
242
+
243
+ async def test_skips_an_already_closed_socket(self) -> None:
244
+ socket = AsyncMock(closed=True)
245
+ client = _connected_client(socket)
246
+ await client.close()
247
+ socket.close.assert_not_awaited()
248
+
249
+ async def test_idempotent_on_a_fresh_client(self) -> None:
250
+ client = WebSocketClient("ws://example.test/s")
251
+ await client.close()
252
+ await client.close()
253
+ assert client.closed is True
File without changes
File without changes
File without changes
File without changes