httpware 0.8.6__tar.gz → 0.9.1__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.
- {httpware-0.8.6 → httpware-0.9.1}/PKG-INFO +11 -8
- {httpware-0.8.6 → httpware-0.9.1}/README.md +9 -6
- {httpware-0.8.6 → httpware-0.9.1}/pyproject.toml +2 -2
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/__init__.py +2 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/client.py +76 -25
- httpware-0.9.1/src/httpware/decoders/__init__.py +42 -0
- httpware-0.9.1/src/httpware/decoders/msgspec.py +116 -0
- httpware-0.9.1/src/httpware/decoders/pydantic.py +81 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/errors.py +40 -0
- httpware-0.8.6/src/httpware/decoders/__init__.py +0 -23
- httpware-0.8.6/src/httpware/decoders/msgspec.py +0 -32
- httpware-0.8.6/src/httpware/decoders/pydantic.py +0 -45
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/status.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/retry.py +0 -0
- {httpware-0.8.6 → httpware-0.9.1}/src/httpware/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpware
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Resilience-first async HTTP client framework for Python
|
|
5
5
|
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
6
|
Author: Artur Shiriev
|
|
@@ -21,7 +21,7 @@ Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
|
21
21
|
Requires-Dist: pydantic>=2.0,<3.0 ; extra == 'pydantic'
|
|
22
22
|
Requires-Python: >=3.11, <4
|
|
23
23
|
Project-URL: repository, https://github.com/modern-python/httpware
|
|
24
|
-
Project-URL: docs, https://httpware.
|
|
24
|
+
Project-URL: docs, https://httpware.modern-python.org
|
|
25
25
|
Provides-Extra: all
|
|
26
26
|
Provides-Extra: msgspec
|
|
27
27
|
Provides-Extra: otel
|
|
@@ -45,12 +45,13 @@ Description-Content-Type: text/markdown
|
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
pip install httpware # core only — no decoder
|
|
48
|
-
pip install httpware[pydantic] # + PydanticDecoder
|
|
49
|
-
pip install httpware[msgspec] # + MsgspecDecoder
|
|
48
|
+
pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics
|
|
49
|
+
pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics
|
|
50
|
+
pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec
|
|
50
51
|
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
51
52
|
```
|
|
52
53
|
|
|
53
|
-
`AsyncClient()`
|
|
54
|
+
`AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model.
|
|
54
55
|
|
|
55
56
|
## Quickstart
|
|
56
57
|
|
|
@@ -79,7 +80,7 @@ with Client(base_url="https://example.test") as client:
|
|
|
79
80
|
print(response.json())
|
|
80
81
|
```
|
|
81
82
|
|
|
82
|
-
Typed decoding via `response_model=` works in both worlds —
|
|
83
|
+
Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
83
84
|
|
|
84
85
|
```python
|
|
85
86
|
from httpware import AsyncClient
|
|
@@ -118,7 +119,7 @@ async def main() -> None:
|
|
|
118
119
|
user = await client.get("/users/1", response_model=User)
|
|
119
120
|
```
|
|
120
121
|
|
|
121
|
-
Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](
|
|
122
|
+
Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](https://httpware.modern-python.org/middleware/).
|
|
122
123
|
|
|
123
124
|
### Streaming responses
|
|
124
125
|
|
|
@@ -165,11 +166,13 @@ pip install httpware[otel]
|
|
|
165
166
|
|
|
166
167
|
When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
|
|
167
168
|
|
|
169
|
+
## 📚 [Documentation](https://httpware.modern-python.org)
|
|
170
|
+
|
|
168
171
|
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
169
172
|
|
|
170
173
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
171
174
|
|
|
172
|
-
## 📝 [License](
|
|
175
|
+
## 📝 [License](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
173
176
|
|
|
174
177
|
## Part of `modern-python`
|
|
175
178
|
|
|
@@ -15,12 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
pip install httpware # core only — no decoder
|
|
18
|
-
pip install httpware[pydantic] # + PydanticDecoder
|
|
19
|
-
pip install httpware[msgspec] # + MsgspecDecoder
|
|
18
|
+
pip install httpware[pydantic] # + PydanticDecoder — handles BaseModel + dataclasses + primitives + generics
|
|
19
|
+
pip install httpware[msgspec] # + MsgspecDecoder — handles Struct + dataclasses + primitives + generics
|
|
20
|
+
pip install httpware[pydantic,msgspec] # both extras — both decoders register; BaseModel routes to pydantic, Struct to msgspec
|
|
20
21
|
pip install httpware[all] # everything declared above (pydantic, msgspec, otel)
|
|
21
22
|
```
|
|
22
23
|
|
|
23
|
-
`AsyncClient()`
|
|
24
|
+
`AsyncClient()` resolves `decoders=None` against installed extras: pydantic if installed (first), msgspec if installed (second), or an empty tuple if neither. `AsyncClient()` never raises on missing extras — failure is deferred to the first `response_model=` call, where `MissingDecoderError` fires *before* the HTTP request if no registered decoder claims the model.
|
|
24
25
|
|
|
25
26
|
## Quickstart
|
|
26
27
|
|
|
@@ -49,7 +50,7 @@ with Client(base_url="https://example.test") as client:
|
|
|
49
50
|
print(response.json())
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
Typed decoding via `response_model=` works in both worlds —
|
|
53
|
+
Typed decoding via `response_model=` works in both worlds — install either `pip install httpware[pydantic]` or `pip install httpware[msgspec]` (or both; pydantic is tried first when both are present). Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
|
|
53
54
|
|
|
54
55
|
```python
|
|
55
56
|
from httpware import AsyncClient
|
|
@@ -88,7 +89,7 @@ async def main() -> None:
|
|
|
88
89
|
user = await client.get("/users/1", response_model=User)
|
|
89
90
|
```
|
|
90
91
|
|
|
91
|
-
Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](
|
|
92
|
+
Need a custom middleware (auth, tracing, request-ID propagation, etc.)? See the [Middleware guide](https://httpware.modern-python.org/middleware/).
|
|
92
93
|
|
|
93
94
|
### Streaming responses
|
|
94
95
|
|
|
@@ -135,11 +136,13 @@ pip install httpware[otel]
|
|
|
135
136
|
|
|
136
137
|
When installed, `_emit_event` calls `trace.get_current_span().add_event(name, attributes=...)` automatically. We never create our own spans; for HTTP-level tracing install `opentelemetry-instrumentation-httpx` separately.
|
|
137
138
|
|
|
139
|
+
## 📚 [Documentation](https://httpware.modern-python.org)
|
|
140
|
+
|
|
138
141
|
## 🗒️ [Release notes](https://github.com/modern-python/httpware/releases)
|
|
139
142
|
|
|
140
143
|
## 📦 [PyPI](https://pypi.org/project/httpware)
|
|
141
144
|
|
|
142
|
-
## 📝 [License](
|
|
145
|
+
## 📝 [License](https://github.com/modern-python/httpware/blob/main/LICENSE)
|
|
143
146
|
|
|
144
147
|
## Part of `modern-python`
|
|
145
148
|
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Internet :: WWW/HTTP",
|
|
27
27
|
"Framework :: AsyncIO",
|
|
28
28
|
]
|
|
29
|
-
version = "0.
|
|
29
|
+
version = "0.9.1"
|
|
30
30
|
dependencies = [
|
|
31
31
|
"httpx2>=2.0.0,<3.0",
|
|
32
32
|
]
|
|
@@ -39,7 +39,7 @@ all = ["httpware[pydantic,msgspec,otel]"]
|
|
|
39
39
|
|
|
40
40
|
[project.urls]
|
|
41
41
|
repository = "https://github.com/modern-python/httpware"
|
|
42
|
-
docs = "https://httpware.
|
|
42
|
+
docs = "https://httpware.modern-python.org"
|
|
43
43
|
|
|
44
44
|
[build-system]
|
|
45
45
|
requires = ["uv_build>=0.11,<1.0"]
|
|
@@ -12,6 +12,7 @@ from httpware.errors import (
|
|
|
12
12
|
DecodeError,
|
|
13
13
|
ForbiddenError,
|
|
14
14
|
InternalServerError,
|
|
15
|
+
MissingDecoderError,
|
|
15
16
|
NetworkError,
|
|
16
17
|
NotFoundError,
|
|
17
18
|
RateLimitedError,
|
|
@@ -57,6 +58,7 @@ __all__ = [
|
|
|
57
58
|
"ForbiddenError",
|
|
58
59
|
"InternalServerError",
|
|
59
60
|
"Middleware",
|
|
61
|
+
"MissingDecoderError",
|
|
60
62
|
"NetworkError",
|
|
61
63
|
"Next",
|
|
62
64
|
"NotFoundError",
|
|
@@ -16,7 +16,7 @@ from httpware._internal.status import (
|
|
|
16
16
|
_raise_on_status_error,
|
|
17
17
|
)
|
|
18
18
|
from httpware.decoders import ResponseDecoder
|
|
19
|
-
from httpware.errors import DecodeError, TransportError
|
|
19
|
+
from httpware.errors import DecodeError, MissingDecoderError, TransportError
|
|
20
20
|
from httpware.middleware import AsyncMiddleware, AsyncNext, Middleware, Next
|
|
21
21
|
from httpware.middleware.chain import compose, compose_async
|
|
22
22
|
|
|
@@ -30,19 +30,26 @@ _HTTPX2_CLIENT_CONFLICT_MESSAGE = (
|
|
|
30
30
|
f"{_FORWARDED_KWARG_NAMES}; configure the httpx2 client you pass instead."
|
|
31
31
|
)
|
|
32
32
|
|
|
33
|
-
_DEFAULT_DECODER_MISSING_MESSAGE = (
|
|
34
|
-
"decoder=None defaults to PydanticDecoder, which requires the "
|
|
35
|
-
"'pydantic' extra. Either install it (`pip install httpware[pydantic]`) or "
|
|
36
|
-
"pass an explicit decoder=..."
|
|
37
|
-
)
|
|
38
33
|
|
|
34
|
+
def _build_default_decoders() -> tuple[ResponseDecoder, ...]:
|
|
35
|
+
"""Construct the default decoder tuple based on installed extras.
|
|
36
|
+
|
|
37
|
+
Pydantic-first when both extras are present; either-only when only one is
|
|
38
|
+
installed; empty tuple when neither is installed. Imports the concrete
|
|
39
|
+
decoder modules lazily so missing extras never trip `find_spec`-guarded
|
|
40
|
+
import paths. Called by `AsyncClient.__init__` and `Client.__init__` when
|
|
41
|
+
`decoders=None` (the default).
|
|
42
|
+
"""
|
|
43
|
+
decoders: list[ResponseDecoder] = []
|
|
44
|
+
if import_checker.is_pydantic_installed:
|
|
45
|
+
from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design (Seam C)
|
|
39
46
|
|
|
40
|
-
|
|
41
|
-
if
|
|
42
|
-
|
|
43
|
-
from httpware.decoders.pydantic import PydanticDecoder # noqa: PLC0415 — lazy by design
|
|
47
|
+
decoders.append(PydanticDecoder())
|
|
48
|
+
if import_checker.is_msgspec_installed:
|
|
49
|
+
from httpware.decoders.msgspec import MsgspecDecoder # noqa: PLC0415 — lazy by design (Seam C)
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
decoders.append(MsgspecDecoder())
|
|
52
|
+
return tuple(decoders)
|
|
46
53
|
|
|
47
54
|
|
|
48
55
|
@contextlib.asynccontextmanager
|
|
@@ -72,7 +79,7 @@ class AsyncClient:
|
|
|
72
79
|
|
|
73
80
|
_httpx2_client: httpx2.AsyncClient
|
|
74
81
|
_owns_client: bool
|
|
75
|
-
|
|
82
|
+
_decoders: tuple[ResponseDecoder, ...]
|
|
76
83
|
_user_middleware: tuple[AsyncMiddleware, ...]
|
|
77
84
|
_dispatch: AsyncNext
|
|
78
85
|
|
|
@@ -87,7 +94,7 @@ class AsyncClient:
|
|
|
87
94
|
limits: httpx2.Limits | None = None,
|
|
88
95
|
auth: httpx2.Auth | None = None,
|
|
89
96
|
httpx2_client: httpx2.AsyncClient | None = None,
|
|
90
|
-
|
|
97
|
+
decoders: Sequence[ResponseDecoder] | None = None,
|
|
91
98
|
middleware: Sequence[AsyncMiddleware] = (),
|
|
92
99
|
) -> None:
|
|
93
100
|
if httpx2_client is not None:
|
|
@@ -123,10 +130,17 @@ class AsyncClient:
|
|
|
123
130
|
self._httpx2_client = httpx2.AsyncClient(**kwargs)
|
|
124
131
|
self._owns_client = True
|
|
125
132
|
|
|
126
|
-
self.
|
|
133
|
+
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
|
|
127
134
|
self._user_middleware = tuple(middleware)
|
|
128
135
|
self._dispatch = compose_async(self._user_middleware, self._terminal)
|
|
129
136
|
|
|
137
|
+
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
|
|
138
|
+
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
|
|
139
|
+
for decoder in self._decoders:
|
|
140
|
+
if decoder.can_decode(model):
|
|
141
|
+
return decoder
|
|
142
|
+
return None
|
|
143
|
+
|
|
130
144
|
async def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
131
145
|
try:
|
|
132
146
|
async with _httpx2_exception_mapper():
|
|
@@ -151,11 +165,19 @@ class AsyncClient:
|
|
|
151
165
|
response_model: type[T] | None = None,
|
|
152
166
|
) -> httpx2.Response | T:
|
|
153
167
|
"""Send `request` through the middleware chain. Decode if `response_model` is set."""
|
|
154
|
-
response = await self._dispatch(request)
|
|
155
168
|
if response_model is None:
|
|
156
|
-
return
|
|
169
|
+
return await self._dispatch(request)
|
|
170
|
+
|
|
171
|
+
decoder = self._dispatch_decoder(response_model)
|
|
172
|
+
if decoder is None:
|
|
173
|
+
raise MissingDecoderError(
|
|
174
|
+
model=response_model,
|
|
175
|
+
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
response = await self._dispatch(request)
|
|
157
179
|
try:
|
|
158
|
-
return
|
|
180
|
+
return decoder.decode(response.content, response_model)
|
|
159
181
|
except Exception as exc:
|
|
160
182
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
161
183
|
|
|
@@ -174,9 +196,16 @@ class AsyncClient:
|
|
|
174
196
|
Not for streaming responses — decodes ``response.content``, which
|
|
175
197
|
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
176
198
|
"""
|
|
199
|
+
decoder = self._dispatch_decoder(response_model)
|
|
200
|
+
if decoder is None:
|
|
201
|
+
raise MissingDecoderError(
|
|
202
|
+
model=response_model,
|
|
203
|
+
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
204
|
+
)
|
|
205
|
+
|
|
177
206
|
response = await self._dispatch(request)
|
|
178
207
|
try:
|
|
179
|
-
decoded =
|
|
208
|
+
decoded = decoder.decode(response.content, response_model)
|
|
180
209
|
except Exception as exc:
|
|
181
210
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
182
211
|
return response, decoded
|
|
@@ -790,7 +819,7 @@ class Client:
|
|
|
790
819
|
|
|
791
820
|
_httpx2_client: httpx2.Client
|
|
792
821
|
_owns_client: bool
|
|
793
|
-
|
|
822
|
+
_decoders: tuple[ResponseDecoder, ...]
|
|
794
823
|
_user_middleware: tuple[Middleware, ...]
|
|
795
824
|
_dispatch: Next
|
|
796
825
|
|
|
@@ -805,7 +834,7 @@ class Client:
|
|
|
805
834
|
limits: httpx2.Limits | None = None,
|
|
806
835
|
auth: httpx2.Auth | None = None,
|
|
807
836
|
httpx2_client: httpx2.Client | None = None,
|
|
808
|
-
|
|
837
|
+
decoders: Sequence[ResponseDecoder] | None = None,
|
|
809
838
|
middleware: Sequence[Middleware] = (),
|
|
810
839
|
) -> None:
|
|
811
840
|
if httpx2_client is not None:
|
|
@@ -841,10 +870,17 @@ class Client:
|
|
|
841
870
|
self._httpx2_client = httpx2.Client(**kwargs)
|
|
842
871
|
self._owns_client = True
|
|
843
872
|
|
|
844
|
-
self.
|
|
873
|
+
self._decoders = tuple(decoders) if decoders is not None else _build_default_decoders()
|
|
845
874
|
self._user_middleware = tuple(middleware)
|
|
846
875
|
self._dispatch = compose(self._user_middleware, self._terminal)
|
|
847
876
|
|
|
877
|
+
def _dispatch_decoder(self, model: type) -> ResponseDecoder | None:
|
|
878
|
+
"""Walk `_decoders` and return the first decoder claiming `model`, or None."""
|
|
879
|
+
for decoder in self._decoders:
|
|
880
|
+
if decoder.can_decode(model):
|
|
881
|
+
return decoder
|
|
882
|
+
return None
|
|
883
|
+
|
|
848
884
|
def _terminal(self, request: httpx2.Request) -> httpx2.Response:
|
|
849
885
|
try:
|
|
850
886
|
with _httpx2_exception_mapper_sync():
|
|
@@ -893,11 +929,19 @@ class Client:
|
|
|
893
929
|
response_model: type[T] | None = None,
|
|
894
930
|
) -> httpx2.Response | T:
|
|
895
931
|
"""Send `request` through the middleware chain. Decode if `response_model` is set."""
|
|
896
|
-
response = self._dispatch(request)
|
|
897
932
|
if response_model is None:
|
|
898
|
-
return
|
|
933
|
+
return self._dispatch(request)
|
|
934
|
+
|
|
935
|
+
decoder = self._dispatch_decoder(response_model)
|
|
936
|
+
if decoder is None:
|
|
937
|
+
raise MissingDecoderError(
|
|
938
|
+
model=response_model,
|
|
939
|
+
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
response = self._dispatch(request)
|
|
899
943
|
try:
|
|
900
|
-
return
|
|
944
|
+
return decoder.decode(response.content, response_model)
|
|
901
945
|
except Exception as exc:
|
|
902
946
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
903
947
|
|
|
@@ -916,9 +960,16 @@ class Client:
|
|
|
916
960
|
Not for streaming responses — decodes ``response.content``, which
|
|
917
961
|
requires the body to be fully read. Use ``stream()`` for streaming.
|
|
918
962
|
"""
|
|
963
|
+
decoder = self._dispatch_decoder(response_model)
|
|
964
|
+
if decoder is None:
|
|
965
|
+
raise MissingDecoderError(
|
|
966
|
+
model=response_model,
|
|
967
|
+
registered_names=tuple(type(d).__name__ for d in self._decoders),
|
|
968
|
+
)
|
|
969
|
+
|
|
919
970
|
response = self._dispatch(request)
|
|
920
971
|
try:
|
|
921
|
-
decoded =
|
|
972
|
+
decoded = decoder.decode(response.content, response_model)
|
|
922
973
|
except Exception as exc:
|
|
923
974
|
raise DecodeError(response=response, model=response_model, original=exc) from exc
|
|
924
975
|
return response, decoded
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""ResponseDecoder protocol — the Client/AsyncClient ↔ ResponseDecoder seam (Seam B)."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, TypeVar, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
T = TypeVar("T")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class ResponseDecoder(Protocol):
|
|
11
|
+
"""Structural protocol every response-body decoder satisfies."""
|
|
12
|
+
|
|
13
|
+
def can_decode(self, model: type) -> bool:
|
|
14
|
+
"""Return True iff this decoder claims responsibility for `model`.
|
|
15
|
+
|
|
16
|
+
The client walks its `_decoders` tuple in order and picks the first
|
|
17
|
+
decoder whose `can_decode` returns True. Implementations should claim
|
|
18
|
+
every model type they can actually handle — broad is correct, because
|
|
19
|
+
list ordering encodes the caller's preference for shared shapes.
|
|
20
|
+
Native types of another library (e.g. `PydanticDecoder` vs
|
|
21
|
+
`msgspec.Struct`) MUST be rejected.
|
|
22
|
+
|
|
23
|
+
`can_decode` MUST NOT raise. It runs at dispatch time — before the HTTP
|
|
24
|
+
call and outside the `DecodeError` wrap that protects `decode` — so an
|
|
25
|
+
exception here escapes the `ClientError` contract rather than being
|
|
26
|
+
translated. A decoder that cannot determine support for `model` must
|
|
27
|
+
return False (decline), not raise; the built-in decoders treat any
|
|
28
|
+
probe failure as False.
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def decode(self, content: bytes, model: type[T]) -> T:
|
|
33
|
+
"""Decode `content` (raw response bytes) into an instance of `model`.
|
|
34
|
+
|
|
35
|
+
Any exception raised by `decode` is wrapped by `Client.send` /
|
|
36
|
+
`AsyncClient.send` into `httpware.DecodeError`; implementers do not
|
|
37
|
+
need to raise `DecodeError` directly.
|
|
38
|
+
"""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["ResponseDecoder"]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""MsgspecDecoder — opt-in ResponseDecoder backed by a per-instance msgspec.json.Decoder cache."""
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
from typing import TypeVar
|
|
5
|
+
|
|
6
|
+
from httpware._internal import import_checker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if import_checker.is_msgspec_installed:
|
|
10
|
+
import msgspec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _contains_custom_type(info: "msgspec.inspect.Type") -> bool:
|
|
19
|
+
"""Return True if `info` is a CustomType or nests one in its parameters.
|
|
20
|
+
|
|
21
|
+
Walks generic-container parameterization (list/dict/set/tuple/union element
|
|
22
|
+
types) by visiting any attribute that is itself a `msgspec.inspect.Type` or a
|
|
23
|
+
tuple of them. It deliberately does NOT descend into `StructType`/dataclass
|
|
24
|
+
fields: those expose `fields` as `Field` objects (not `Type`), so the walk
|
|
25
|
+
stops at the boundary of a type msgspec natively owns. That boundary is what
|
|
26
|
+
makes the walk both correct (a Struct is a valid target) and safe against
|
|
27
|
+
infinite recursion on self-referential struct definitions.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(info, msgspec.inspect.CustomType):
|
|
30
|
+
return True
|
|
31
|
+
for name in dir(info):
|
|
32
|
+
if name.startswith("_"):
|
|
33
|
+
continue
|
|
34
|
+
value = getattr(info, name, None)
|
|
35
|
+
if isinstance(value, msgspec.inspect.Type):
|
|
36
|
+
if _contains_custom_type(value):
|
|
37
|
+
return True
|
|
38
|
+
elif (
|
|
39
|
+
isinstance(value, tuple)
|
|
40
|
+
and value
|
|
41
|
+
and all(isinstance(item, msgspec.inspect.Type) for item in value)
|
|
42
|
+
and any(_contains_custom_type(item) for item in value)
|
|
43
|
+
):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MsgspecDecoder:
|
|
49
|
+
"""Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
|
|
50
|
+
|
|
51
|
+
Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
|
|
52
|
+
this module without the extra works (the `msgspec` import is guarded by a
|
|
53
|
+
`find_spec` check), but instantiating the decoder raises `ImportError`.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
|
|
57
|
+
_can_decode_results: dict[type, bool]
|
|
58
|
+
|
|
59
|
+
def __init__(self) -> None:
|
|
60
|
+
if not import_checker.is_msgspec_installed:
|
|
61
|
+
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
62
|
+
self._msgspec_decoders = {}
|
|
63
|
+
self._can_decode_results = {}
|
|
64
|
+
|
|
65
|
+
def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
|
|
66
|
+
decoder = self._msgspec_decoders.get(model)
|
|
67
|
+
if decoder is None:
|
|
68
|
+
decoder = msgspec.json.Decoder(model)
|
|
69
|
+
self._msgspec_decoders[model] = decoder
|
|
70
|
+
return decoder
|
|
71
|
+
|
|
72
|
+
def can_decode(self, model: type) -> bool:
|
|
73
|
+
"""Return True iff msgspec natively understands `model` end-to-end.
|
|
74
|
+
|
|
75
|
+
The verdict is memoized per `model`: the probe below (an uncached
|
|
76
|
+
`type_info` call plus a recursive tree walk) runs once per type, not on
|
|
77
|
+
every dispatch. Unhashable models skip the cache and probe fresh.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
cached = self._can_decode_results.get(model)
|
|
81
|
+
except TypeError: # unhashable model — can't memoize, probe fresh
|
|
82
|
+
return self._probe_can_decode(model)
|
|
83
|
+
if cached is not None:
|
|
84
|
+
return cached
|
|
85
|
+
result = self._probe_can_decode(model)
|
|
86
|
+
self._can_decode_results[model] = result
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
def _probe_can_decode(self, model: type) -> bool:
|
|
90
|
+
"""Decide whether msgspec natively decodes `model` (the uncached path).
|
|
91
|
+
|
|
92
|
+
msgspec builds a Decoder for almost any class via a generic CustomType
|
|
93
|
+
fallback; the Decoder constructor does NOT raise on unsupported types
|
|
94
|
+
(e.g. pydantic.BaseModel, or a container parameterized by one). We walk
|
|
95
|
+
msgspec.inspect.type_info and reject if a CustomType appears anywhere in
|
|
96
|
+
the type tree, so MissingDecoderError fires before a request is sent.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
info = msgspec.inspect.type_info(model)
|
|
100
|
+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
101
|
+
return False
|
|
102
|
+
if _contains_custom_type(info):
|
|
103
|
+
return False
|
|
104
|
+
try:
|
|
105
|
+
self._get_msgspec_decoder(model)
|
|
106
|
+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
107
|
+
return False
|
|
108
|
+
return True
|
|
109
|
+
|
|
110
|
+
def decode(self, content: bytes, model: type[T]) -> T:
|
|
111
|
+
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
112
|
+
try:
|
|
113
|
+
decoder = self._get_msgspec_decoder(model)
|
|
114
|
+
except TypeError:
|
|
115
|
+
decoder = msgspec.json.Decoder(model)
|
|
116
|
+
return decoder.decode(content)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""PydanticDecoder — ResponseDecoder backed by per-instance TypeAdapter cache.
|
|
2
|
+
|
|
3
|
+
Requires the `pydantic` extra: `pip install httpware[pydantic]`. Constructing
|
|
4
|
+
`PydanticDecoder()` directly when pydantic is not installed raises ImportError.
|
|
5
|
+
The default-decoder path in `client.py:_build_default_decoders()` skips this
|
|
6
|
+
class entirely when `is_pydantic_installed` is False, so `AsyncClient()` does
|
|
7
|
+
not trip the ImportError when the user is not using `response_model=`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import typing
|
|
11
|
+
from typing import TypeVar
|
|
12
|
+
|
|
13
|
+
from pydantic import TypeAdapter
|
|
14
|
+
|
|
15
|
+
from httpware._internal import import_checker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MISSING_DEPENDENCY_MESSAGE = (
|
|
19
|
+
"PydanticDecoder requires the 'pydantic' extra. Install with: pip install httpware[pydantic]"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PydanticDecoder:
|
|
26
|
+
"""Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`."""
|
|
27
|
+
|
|
28
|
+
_adapters: dict[type, TypeAdapter[typing.Any]]
|
|
29
|
+
_can_decode_results: dict[type, bool]
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
if not import_checker.is_pydantic_installed:
|
|
33
|
+
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
34
|
+
self._adapters = {}
|
|
35
|
+
self._can_decode_results = {}
|
|
36
|
+
|
|
37
|
+
def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
|
|
38
|
+
adapter = self._adapters.get(model)
|
|
39
|
+
if adapter is None:
|
|
40
|
+
adapter = TypeAdapter(model)
|
|
41
|
+
self._adapters[model] = adapter
|
|
42
|
+
return adapter
|
|
43
|
+
|
|
44
|
+
def can_decode(self, model: type) -> bool:
|
|
45
|
+
"""Return True iff pydantic can build a schema for `model`.
|
|
46
|
+
|
|
47
|
+
The verdict is memoized per `model` so a rejection (which costs a
|
|
48
|
+
`PydanticSchemaGenerationError` round-trip) is not re-probed on every
|
|
49
|
+
dispatch. Unhashable models skip the cache and probe fresh.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
cached = self._can_decode_results.get(model)
|
|
53
|
+
except TypeError: # unhashable model — can't memoize, probe fresh
|
|
54
|
+
return self._probe_can_decode(model)
|
|
55
|
+
if cached is not None:
|
|
56
|
+
return cached
|
|
57
|
+
result = self._probe_can_decode(model)
|
|
58
|
+
self._can_decode_results[model] = result
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def _probe_can_decode(self, model: type) -> bool:
|
|
62
|
+
"""Decide whether pydantic can build a schema for `model` (uncached).
|
|
63
|
+
|
|
64
|
+
Probes via `_get_adapter`; subsequent calls (including `decode`) reuse
|
|
65
|
+
the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses —
|
|
66
|
+
pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when
|
|
67
|
+
building a schema for them.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
self._get_adapter(model)
|
|
71
|
+
except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
|
|
72
|
+
return False
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def decode(self, content: bytes, model: type[T]) -> T:
|
|
76
|
+
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
77
|
+
try:
|
|
78
|
+
adapter = self._get_adapter(model)
|
|
79
|
+
except TypeError:
|
|
80
|
+
adapter = TypeAdapter(model)
|
|
81
|
+
return adapter.validate_json(content)
|
|
@@ -254,3 +254,43 @@ class DecodeError(ClientError):
|
|
|
254
254
|
_reconstruct_decode_error,
|
|
255
255
|
(type(self), self.response, self.model, self.original),
|
|
256
256
|
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _missing_decoder_summary(model: type, registered_names: tuple[str, ...]) -> str:
|
|
260
|
+
if not registered_names:
|
|
261
|
+
hint = (
|
|
262
|
+
"no decoders registered. Install `pip install httpware[pydantic]` "
|
|
263
|
+
"or `pip install httpware[msgspec]`, or pass decoders=[...] explicitly."
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
joined = " + ".join(registered_names)
|
|
267
|
+
hint = f"registered decoders ({joined}) all rejected it. Pass a custom decoder via decoders=[...]."
|
|
268
|
+
return f"no decoder for response_model={model!r}: {hint}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _reconstruct_missing_decoder(
|
|
272
|
+
cls: "type[MissingDecoderError]",
|
|
273
|
+
model: type,
|
|
274
|
+
registered_names: tuple[str, ...],
|
|
275
|
+
) -> "MissingDecoderError":
|
|
276
|
+
return cls(model=model, registered_names=registered_names)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class MissingDecoderError(ClientError):
|
|
280
|
+
"""Raised when response_model= is set but no registered decoder claims the model.
|
|
281
|
+
|
|
282
|
+
Fires at .send() entry, BEFORE the HTTP call — no point sending a request
|
|
283
|
+
whose response cannot be decoded. Distinct from DecodeError, which means
|
|
284
|
+
the decoder ran and the payload was malformed.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
model: type
|
|
288
|
+
registered_names: tuple[str, ...]
|
|
289
|
+
|
|
290
|
+
def __init__(self, *, model: type, registered_names: tuple[str, ...]) -> None:
|
|
291
|
+
self.model = model
|
|
292
|
+
self.registered_names = registered_names
|
|
293
|
+
super().__init__(_missing_decoder_summary(model, registered_names))
|
|
294
|
+
|
|
295
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
296
|
+
return (_reconstruct_missing_decoder, (type(self), self.model, self.registered_names))
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
"""ResponseDecoder protocol — the Client/AsyncClient ↔ ResponseDecoder seam (Seam B)."""
|
|
2
|
-
|
|
3
|
-
from typing import Protocol, TypeVar, runtime_checkable
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
T = TypeVar("T")
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@runtime_checkable
|
|
10
|
-
class ResponseDecoder(Protocol):
|
|
11
|
-
"""Structural protocol every response-body decoder satisfies."""
|
|
12
|
-
|
|
13
|
-
def decode(self, content: bytes, model: type[T]) -> T:
|
|
14
|
-
"""Decode `content` (raw response bytes) into an instance of `model`.
|
|
15
|
-
|
|
16
|
-
Any exception raised by `decode` is wrapped by `Client.send` /
|
|
17
|
-
`AsyncClient.send` into `httpware.DecodeError`; implementers do not
|
|
18
|
-
need to raise `DecodeError` directly.
|
|
19
|
-
"""
|
|
20
|
-
...
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
__all__ = ["ResponseDecoder"]
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
"""MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode."""
|
|
2
|
-
|
|
3
|
-
from typing import TypeVar
|
|
4
|
-
|
|
5
|
-
from httpware._internal import import_checker
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if import_checker.is_msgspec_installed:
|
|
9
|
-
import msgspec
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
|
|
13
|
-
|
|
14
|
-
T = TypeVar("T")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class MsgspecDecoder:
|
|
18
|
-
"""Decode raw response bytes via `msgspec.json.decode(content, type=model)`.
|
|
19
|
-
|
|
20
|
-
Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
|
|
21
|
-
this module without the extra works (the `msgspec` import is guarded by a
|
|
22
|
-
`find_spec` check), but instantiating the decoder raises `ImportError` with
|
|
23
|
-
the install hint.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def __init__(self) -> None:
|
|
27
|
-
if not import_checker.is_msgspec_installed:
|
|
28
|
-
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
29
|
-
|
|
30
|
-
def decode(self, content: bytes, model: type[T]) -> T:
|
|
31
|
-
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
32
|
-
return msgspec.json.decode(content, type=model)
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""PydanticDecoder — module-level cached TypeAdapter adapter for ResponseDecoder.
|
|
2
|
-
|
|
3
|
-
Requires the `pydantic` extra: `pip install httpware[pydantic]`. The optional-extras
|
|
4
|
-
gate is enforced upstream — `client.py:_default_pydantic_decoder()` raises
|
|
5
|
-
ImportError when pydantic is absent, so this module is never imported in that
|
|
6
|
-
path. Tests simulating "pydantic not installed" patch
|
|
7
|
-
`import_checker.is_pydantic_installed=False` at runtime, after this module is
|
|
8
|
-
already loaded; `PydanticDecoder.__init__` then raises ImportError with the
|
|
9
|
-
install hint.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
import functools
|
|
13
|
-
from typing import TypeVar
|
|
14
|
-
|
|
15
|
-
from pydantic import TypeAdapter
|
|
16
|
-
|
|
17
|
-
from httpware._internal import import_checker
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
MISSING_DEPENDENCY_MESSAGE = (
|
|
21
|
-
"PydanticDecoder requires the 'pydantic' extra. Install with: pip install httpware[pydantic]"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
T = TypeVar("T")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@functools.lru_cache(maxsize=1024)
|
|
28
|
-
def _get_adapter(model: type[T]) -> "TypeAdapter[T]":
|
|
29
|
-
return TypeAdapter(model)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class PydanticDecoder:
|
|
33
|
-
"""Decode raw response bytes into `model` via a cached `pydantic.TypeAdapter`."""
|
|
34
|
-
|
|
35
|
-
def __init__(self) -> None:
|
|
36
|
-
if not import_checker.is_pydantic_installed:
|
|
37
|
-
raise ImportError(MISSING_DEPENDENCY_MESSAGE)
|
|
38
|
-
|
|
39
|
-
def decode(self, content: bytes, model: type[T]) -> T:
|
|
40
|
-
"""Validate `content` as JSON against `model` in a single parse pass."""
|
|
41
|
-
try:
|
|
42
|
-
adapter = _get_adapter(model)
|
|
43
|
-
except TypeError:
|
|
44
|
-
adapter = TypeAdapter(model)
|
|
45
|
-
return adapter.validate_json(content)
|
|
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
|