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.
- {httpware-0.9.0 → httpware-0.9.1}/PKG-INFO +2 -2
- {httpware-0.9.0 → httpware-0.9.1}/README.md +1 -1
- {httpware-0.9.0 → httpware-0.9.1}/pyproject.toml +1 -1
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/decoders/__init__.py +7 -0
- httpware-0.9.1/src/httpware/decoders/msgspec.py +116 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/decoders/pydantic.py +19 -0
- httpware-0.9.0/src/httpware/decoders/msgspec.py +0 -66
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/__init__.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/__init__.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/exception_mapping.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/import_checker.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/observability.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/_internal/status.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/client.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/errors.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/__init__.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/chain.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/__init__.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/_backoff.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/budget.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/bulkhead.py +0 -0
- {httpware-0.9.0 → httpware-0.9.1}/src/httpware/middleware/resilience/retry.py +0 -0
- {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.
|
|
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 —
|
|
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 —
|
|
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
|
|
@@ -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
|
|
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
|