httpware 0.9.0__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 (23) hide show
  1. {httpware-0.9.0 → httpware-0.9.1}/PKG-INFO +2 -2
  2. {httpware-0.9.0 → httpware-0.9.1}/README.md +1 -1
  3. {httpware-0.9.0 → httpware-0.9.1}/pyproject.toml +1 -1
  4. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/decoders/__init__.py +7 -0
  5. httpware-0.9.1/src/httpware/decoders/msgspec.py +116 -0
  6. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/decoders/pydantic.py +19 -0
  7. httpware-0.9.0/src/httpware/decoders/msgspec.py +0 -66
  8. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/__init__.py +0 -0
  9. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/__init__.py +0 -0
  10. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/exception_mapping.py +0 -0
  11. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/import_checker.py +0 -0
  12. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/observability.py +0 -0
  13. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/status.py +0 -0
  14. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/client.py +0 -0
  15. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/errors.py +0 -0
  16. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/__init__.py +0 -0
  17. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/chain.py +0 -0
  18. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/__init__.py +0 -0
  19. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
  20. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/budget.py +0 -0
  21. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/bulkhead.py +0 -0
  22. {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/retry.py +0 -0
  23. {httpware-0.9.0 → 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.9.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
@@ -80,7 +80,7 @@ with Client(base_url="https://example.test") as client:
80
80
  print(response.json())
81
81
  ```
82
82
 
83
- 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.
84
84
 
85
85
  ```python
86
86
  from httpware import AsyncClient
@@ -50,7 +50,7 @@ with Client(base_url="https://example.test") as client:
50
50
  print(response.json())
51
51
  ```
52
52
 
53
- 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.
54
54
 
55
55
  ```python
56
56
  from httpware import AsyncClient
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Internet :: WWW/HTTP",
27
27
  "Framework :: AsyncIO",
28
28
  ]
29
- version = "0.9.0"
29
+ version = "0.9.1"
30
30
  dependencies = [
31
31
  "httpx2>=2.0.0,<3.0",
32
32
  ]
@@ -19,6 +19,13 @@ class ResponseDecoder(Protocol):
19
19
  list ordering encodes the caller's preference for shared shapes.
20
20
  Native types of another library (e.g. `PydanticDecoder` vs
21
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.
22
29
  """
23
30
  ...
24
31
 
@@ -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)
@@ -26,11 +26,13 @@ class PydanticDecoder:
26
26
  """Decode raw response bytes into `model` via a per-instance cached `pydantic.TypeAdapter`."""
27
27
 
28
28
  _adapters: dict[type, TypeAdapter[typing.Any]]
29
+ _can_decode_results: dict[type, bool]
29
30
 
30
31
  def __init__(self) -> None:
31
32
  if not import_checker.is_pydantic_installed:
32
33
  raise ImportError(MISSING_DEPENDENCY_MESSAGE)
33
34
  self._adapters = {}
35
+ self._can_decode_results = {}
34
36
 
35
37
  def _get_adapter(self, model: type[T]) -> "TypeAdapter[T]":
36
38
  adapter = self._adapters.get(model)
@@ -42,6 +44,23 @@ class PydanticDecoder:
42
44
  def can_decode(self, model: type) -> bool:
43
45
  """Return True iff pydantic can build a schema for `model`.
44
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
+
45
64
  Probes via `_get_adapter`; subsequent calls (including `decode`) reuse
46
65
  the cached `TypeAdapter`. Rejects `msgspec.Struct` subclasses —
47
66
  pydantic raises `PydanticSchemaGenerationError` (a `TypeError`) when
@@ -1,66 +0,0 @@
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
- class MsgspecDecoder:
19
- """Decode raw response bytes via a per-instance cached `msgspec.json.Decoder(model)`.
20
-
21
- Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
22
- this module without the extra works (the `msgspec` import is guarded by a
23
- `find_spec` check), but instantiating the decoder raises `ImportError`.
24
- """
25
-
26
- _msgspec_decoders: dict[type, "msgspec.json.Decoder[typing.Any]"]
27
-
28
- def __init__(self) -> None:
29
- if not import_checker.is_msgspec_installed:
30
- raise ImportError(MISSING_DEPENDENCY_MESSAGE)
31
- self._msgspec_decoders = {}
32
-
33
- def _get_msgspec_decoder(self, model: type[T]) -> "msgspec.json.Decoder[T]":
34
- decoder = self._msgspec_decoders.get(model)
35
- if decoder is None:
36
- decoder = msgspec.json.Decoder(model)
37
- self._msgspec_decoders[model] = decoder
38
- return decoder
39
-
40
- def can_decode(self, model: type) -> bool:
41
- """Return True iff msgspec natively understands `model`.
42
-
43
- msgspec builds a Decoder for almost any class via a generic CustomType
44
- fallback; the Decoder constructor itself does NOT raise on unsupported
45
- types (e.g. pydantic.BaseModel). We use msgspec.inspect.type_info
46
- to detect the fallback and reject CustomType results explicitly.
47
- """
48
- try:
49
- info = msgspec.inspect.type_info(model)
50
- except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
51
- return False
52
- if isinstance(info, msgspec.inspect.CustomType):
53
- return False
54
- try:
55
- self._get_msgspec_decoder(model)
56
- except Exception: # noqa: BLE001 — can_decode is a probe; any failure means no
57
- return False
58
- return True
59
-
60
- def decode(self, content: bytes, model: type[T]) -> T:
61
- """Validate `content` as JSON against `model` in a single parse pass."""
62
- try:
63
- decoder = self._get_msgspec_decoder(model)
64
- except TypeError:
65
- decoder = msgspec.json.Decoder(model)
66
- return decoder.decode(content)
File without changes