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.
Files changed (25) hide show
  1. {httpware-0.8.6 → httpware-0.9.1}/PKG-INFO +11 -8
  2. {httpware-0.8.6 → httpware-0.9.1}/README.md +9 -6
  3. {httpware-0.8.6 → httpware-0.9.1}/pyproject.toml +2 -2
  4. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/__init__.py +2 -0
  5. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/client.py +76 -25
  6. httpware-0.9.1/src/httpware/decoders/__init__.py +42 -0
  7. httpware-0.9.1/src/httpware/decoders/msgspec.py +116 -0
  8. httpware-0.9.1/src/httpware/decoders/pydantic.py +81 -0
  9. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/errors.py +40 -0
  10. httpware-0.8.6/src/httpware/decoders/__init__.py +0 -23
  11. httpware-0.8.6/src/httpware/decoders/msgspec.py +0 -32
  12. httpware-0.8.6/src/httpware/decoders/pydantic.py +0 -45
  13. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/__init__.py +0 -0
  14. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/exception_mapping.py +0 -0
  15. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/import_checker.py +0 -0
  16. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/observability.py +0 -0
  17. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/_internal/status.py +0 -0
  18. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/__init__.py +0 -0
  19. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/chain.py +0 -0
  20. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/__init__.py +0 -0
  21. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
  22. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/budget.py +0 -0
  23. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  24. {httpware-0.8.6 → httpware-0.9.1}/src/httpware/middleware/resilience/retry.py +0 -0
  25. {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.8.6
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.readthedocs.io
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 (the default-decoder path)
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()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
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 — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
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](docs/middleware.md).
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](./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 (the default-decoder path)
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()` with no `decoder=` argument defaults to constructing a `PydanticDecoder`; that path requires the `pydantic` extra and raises `ImportError` at `AsyncClient.__init__` if it is missing.
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 — requires `pip install httpware[pydantic]`. Decode failures (malformed body, schema mismatch) raise `httpware.DecodeError`, a `ClientError` subclass — so `except httpware.ClientError` covers them alongside transport and status errors.
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](docs/middleware.md).
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](./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.8.6"
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.readthedocs.io"
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
- def _default_pydantic_decoder() -> ResponseDecoder:
41
- if not import_checker.is_pydantic_installed:
42
- raise ImportError(_DEFAULT_DECODER_MISSING_MESSAGE)
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
- return PydanticDecoder()
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
- _decoder: ResponseDecoder
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
- decoder: ResponseDecoder | None = None,
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._decoder = decoder if decoder is not None else _default_pydantic_decoder()
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 response
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 self._decoder.decode(response.content, response_model)
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 = self._decoder.decode(response.content, response_model)
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
- _decoder: ResponseDecoder
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
- decoder: ResponseDecoder | None = None,
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._decoder = decoder if decoder is not None else _default_pydantic_decoder()
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 response
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 self._decoder.decode(response.content, response_model)
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 = self._decoder.decode(response.content, response_model)
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