quadkit-http 0.0.44__tar.gz → 0.0.46__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.
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/PKG-INFO +6 -6
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/README.md +1 -1
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/pyproject.toml +5 -5
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/__init__.py +6 -1
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/cli/templates/api_client.py.jinja2 +41 -26
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/client/__init__.py +7 -1
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/websocket.py +9 -3
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/cli/test_api_client_generator.py +13 -0
- quadkit_http-0.0.46/tests/unit/test_websocket.py +253 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/.gitignore +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/LICENSE +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/NOTICE +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/example.yaml +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/cli/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/cli/contributor.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/cli/generators/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/cli/generators/api_client.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/client/_streaming.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/client/_verbs.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/client/base_url_client.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/client/http_client.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/config.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/constants.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/decorators.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/di/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/di/provider.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/events.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/exceptions.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/hooks.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/lib/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/lib/format.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/lib/headers.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/lib/url.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/module.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/pool/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/pool/connection_pool.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/protocols.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/py.typed +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/retry/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/retry/policy.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/types.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/validation/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/validation/primitives.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/validation/url.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/py.typed +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/conftest.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/integration/__init__.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/integration/test_http_lifecycle.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/cli/test_generators_contract.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/http_module_test_support.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_base_url_client_streaming_and_events.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_exceptions.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_client.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_client_verbs.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_constants.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_exceptions.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_module_config.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_module_interceptors.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_module_validation.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_providers.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_streaming.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_types.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_types_extended.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_http_url_safety.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_lib_format.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_lib_headers.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_m17_m18_http.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_package_hooks_p2.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_package_structure_p1.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_protocols.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_redirect_ssrf.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_retry_policy.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_retry_policy_canonical.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_round24_hardening.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_url_utils.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_utils.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_validation.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_validation_primitives.py +0 -0
- {quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_validation_url.py +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: quadkit-http
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.46
|
|
4
4
|
Summary: Outbound HTTP client for the Quadkit Framework
|
|
5
|
-
Project-URL: Homepage, https://
|
|
5
|
+
Project-URL: Homepage, https://oridecon.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/dbtinoy-/quadkit
|
|
7
|
-
Project-URL: Documentation, https://
|
|
7
|
+
Project-URL: Documentation, https://oridecon.dev/quadkit/
|
|
8
8
|
Project-URL: Issues, https://github.com/dbtinoy-/quadkit/issues
|
|
9
9
|
Project-URL: Changelog, https://github.com/dbtinoy-/quadkit/blob/main/CHANGELOG.md
|
|
10
|
-
Author-email: Quadkit Framework Team <
|
|
11
|
-
Maintainer-email: Quadkit Framework Team <
|
|
10
|
+
Author-email: Quadkit Framework Team <hello@oridecon.dev>
|
|
11
|
+
Maintainer-email: Quadkit Framework Team <hello@oridecon.dev>
|
|
12
12
|
License: Apache-2.0
|
|
13
13
|
License-File: LICENSE
|
|
14
14
|
License-File: NOTICE
|
|
@@ -60,7 +60,7 @@ use `quadkit-web`. Resilience patterns (retry, circuit breaker) are layered in
|
|
|
60
60
|
through `the optional provider`, not built in by default.
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
> Full documentation: [
|
|
63
|
+
> Full documentation: [oridecon.dev/quadkit](https://oridecon.dev/quadkit)
|
|
64
64
|
## Install
|
|
65
65
|
|
|
66
66
|
```bash
|
|
@@ -16,7 +16,7 @@ use `quadkit-web`. Resilience patterns (retry, circuit breaker) are layered in
|
|
|
16
16
|
through `the optional provider`, not built in by default.
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
> Full documentation: [
|
|
19
|
+
> Full documentation: [oridecon.dev/quadkit](https://oridecon.dev/quadkit)
|
|
20
20
|
## Install
|
|
21
21
|
|
|
22
22
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "quadkit-http"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.46"
|
|
8
8
|
description = "Outbound HTTP client for the Quadkit Framework"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -23,8 +23,8 @@ classifiers = [
|
|
|
23
23
|
"Framework :: AsyncIO",
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
|
-
authors = [{ name = "Quadkit Framework Team", email = "
|
|
27
|
-
maintainers = [{ name = "Quadkit Framework Team", email = "
|
|
26
|
+
authors = [{ name = "Quadkit Framework Team", email = "hello@oridecon.dev" }]
|
|
27
|
+
maintainers = [{ name = "Quadkit Framework Team", email = "hello@oridecon.dev" }]
|
|
28
28
|
|
|
29
29
|
dependencies = [
|
|
30
30
|
"quadkit-contracts>=0.0.2",
|
|
@@ -35,9 +35,9 @@ dependencies = [
|
|
|
35
35
|
]
|
|
36
36
|
|
|
37
37
|
[project.urls]
|
|
38
|
-
Homepage = "https://
|
|
38
|
+
Homepage = "https://oridecon.dev"
|
|
39
39
|
Repository = "https://github.com/dbtinoy-/quadkit"
|
|
40
|
-
Documentation = "https://
|
|
40
|
+
Documentation = "https://oridecon.dev/quadkit/"
|
|
41
41
|
Issues = "https://github.com/dbtinoy-/quadkit/issues"
|
|
42
42
|
Changelog = "https://github.com/dbtinoy-/quadkit/blob/main/CHANGELOG.md"
|
|
43
43
|
|
|
@@ -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
|
|
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,
|
{quadkit_http-0.0.44 → quadkit_http-0.0.46}/src/quadkit/http/cli/templates/api_client.py.jinja2
RENAMED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"""{{ client_name }} API client.
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
"""
|
|
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__ = [
|
|
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
|
|
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) ->
|
|
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__(
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{quadkit_http-0.0.44 → quadkit_http-0.0.46}/tests/unit/test_base_url_client_streaming_and_events.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|