httpware 0.1.0__py3-none-any.whl
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/__init__.py +67 -0
- httpware/_internal/__init__.py +1 -0
- httpware/_internal/chain.py +39 -0
- httpware/_internal/import_checker.py +6 -0
- httpware/client.py +620 -0
- httpware/config.py +40 -0
- httpware/decoders/__init__.py +18 -0
- httpware/decoders/msgspec.py +32 -0
- httpware/decoders/pydantic.py +29 -0
- httpware/errors.py +194 -0
- httpware/middleware/__init__.py +89 -0
- httpware/py.typed +0 -0
- httpware/request.py +55 -0
- httpware/response.py +69 -0
- httpware/transports/__init__.py +27 -0
- httpware/transports/httpx2.py +180 -0
- httpware/transports/recorded.py +84 -0
- httpware-0.1.0.dist-info/METADATA +94 -0
- httpware-0.1.0.dist-info/RECORD +20 -0
- httpware-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""RecordedTransport — built-in Transport test double."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from contextlib import AbstractAsyncContextManager
|
|
5
|
+
|
|
6
|
+
from httpware.request import Request
|
|
7
|
+
from httpware.response import Response, StreamResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RecordedTransport:
|
|
11
|
+
"""Built-in Transport test double.
|
|
12
|
+
|
|
13
|
+
Construct with a route table mapping (method, url) → Response | BaseException.
|
|
14
|
+
`await transport(request)` looks up `(request.method.upper(), request.url)`; on
|
|
15
|
+
match returns the Response or raises the Exception. On no-match, uses the
|
|
16
|
+
`default` (Response, BaseException, or RuntimeError("No route for METHOD URL")
|
|
17
|
+
when None).
|
|
18
|
+
|
|
19
|
+
Every call appends the Request to `transport.requests`. Tests can assert on
|
|
20
|
+
`transport.last_request`, iterate `transport.requests`, or count
|
|
21
|
+
`transport.aclose_calls` for lifecycle assertions.
|
|
22
|
+
|
|
23
|
+
Routes fire indefinitely — the same (method, url) yields the same canned
|
|
24
|
+
Response on every match. To express "different replies on repeat calls",
|
|
25
|
+
swap the route between calls via `add_route(...)` or construct a new
|
|
26
|
+
transport per call.
|
|
27
|
+
|
|
28
|
+
Route and default values may be `BaseException` (not just `Exception`) so
|
|
29
|
+
test code can express `asyncio.CancelledError`, `SystemExit`, or
|
|
30
|
+
`KeyboardInterrupt` — useful for cancellation/shutdown propagation tests.
|
|
31
|
+
These do NOT get caught by user code's `except Exception:`.
|
|
32
|
+
|
|
33
|
+
`stream()` raises NotImplementedError; streaming lands in Epic 4 (Story 4-1).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
routes: Mapping[tuple[str, str], Response | BaseException] | None = None,
|
|
39
|
+
*,
|
|
40
|
+
default: Response | BaseException | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._routes: dict[tuple[str, str], Response | BaseException] = (
|
|
43
|
+
{(m.upper(), u): v for (m, u), v in routes.items()} if routes is not None else {}
|
|
44
|
+
)
|
|
45
|
+
self._default = default
|
|
46
|
+
self.requests: list[Request] = []
|
|
47
|
+
self.aclose_calls = 0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def last_request(self) -> Request | None:
|
|
51
|
+
"""The most recently observed Request, or None if no calls have been made."""
|
|
52
|
+
return self.requests[-1] if self.requests else None
|
|
53
|
+
|
|
54
|
+
def add_route(
|
|
55
|
+
self,
|
|
56
|
+
method: str,
|
|
57
|
+
url: str,
|
|
58
|
+
response_or_exception: Response | BaseException,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Add or replace a route entry."""
|
|
61
|
+
self._routes[(method.upper(), url)] = response_or_exception
|
|
62
|
+
|
|
63
|
+
async def __call__(self, request: Request) -> Response:
|
|
64
|
+
self.requests.append(request)
|
|
65
|
+
key = (request.method.upper(), request.url)
|
|
66
|
+
result: Response | BaseException | None
|
|
67
|
+
result = self._routes.get(key, self._default)
|
|
68
|
+
if isinstance(result, BaseException):
|
|
69
|
+
raise result
|
|
70
|
+
if result is None:
|
|
71
|
+
msg = f"No route for {request.method} {request.url}"
|
|
72
|
+
raise RuntimeError(msg)
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def stream(
|
|
76
|
+
self,
|
|
77
|
+
request: Request,
|
|
78
|
+
) -> AbstractAsyncContextManager[StreamResponse]:
|
|
79
|
+
"""Streaming not implemented in v0 — landing in Epic 4 (Story 4-1)."""
|
|
80
|
+
msg = "RecordedTransport.stream() is not implemented; streaming lands in Epic 4"
|
|
81
|
+
raise NotImplementedError(msg)
|
|
82
|
+
|
|
83
|
+
async def aclose(self) -> None:
|
|
84
|
+
self.aclose_calls += 1
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpware
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resilience-first async HTTP client framework for Python
|
|
5
|
+
Keywords: http,async,client,resilience,retry,circuit-breaker,middleware,httpx,pydantic
|
|
6
|
+
Author: Artur Shiriev
|
|
7
|
+
Author-email: Artur Shiriev <me@shiriev.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Framework :: AsyncIO
|
|
17
|
+
Requires-Dist: httpx2>=2.0.0,<3.0
|
|
18
|
+
Requires-Dist: pydantic>=2.0,<3.0
|
|
19
|
+
Requires-Dist: httpware[msgspec,otel,niquests] ; extra == 'all'
|
|
20
|
+
Requires-Dist: msgspec>=0.18 ; extra == 'msgspec'
|
|
21
|
+
Requires-Dist: niquests>=3.18 ; extra == 'niquests'
|
|
22
|
+
Requires-Dist: opentelemetry-api>=1.20 ; extra == 'otel'
|
|
23
|
+
Requires-Dist: opentelemetry-sdk>=1.20 ; extra == 'otel'
|
|
24
|
+
Requires-Python: >=3.11, <4
|
|
25
|
+
Project-URL: repository, https://github.com/modern-python/httpware
|
|
26
|
+
Project-URL: docs, https://httpware.readthedocs.io
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Provides-Extra: msgspec
|
|
29
|
+
Provides-Extra: niquests
|
|
30
|
+
Provides-Extra: otel
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# httpware
|
|
34
|
+
|
|
35
|
+
[](https://github.com/modern-python/httpware/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/httpware/)
|
|
37
|
+
[](https://pypi.org/project/httpware/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
**Async HTTP client framework for Python.**
|
|
41
|
+
|
|
42
|
+
`httpware` is a typed, async HTTP client library built on `httpx2` with a protocol-based seam so the transport is swappable. Middleware composes via an onion model. Pydantic and msgspec response decoding ship out of the box. `RecordedTransport` replaces respx for transport-level tests.
|
|
43
|
+
|
|
44
|
+
> **Status:** Pre-1.0 (0.1.0 alpha). Public API is subject to change between minor releases until v1.0. Resilience middleware (retry / timeout / bulkhead), streaming, and observability are not yet shipped — track progress on GitHub.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install httpware
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Optional extras:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install httpware[msgspec] # MsgspecDecoder
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
(`otel`, `niquests`, and `all` extras are declared but their integrations have not shipped yet.)
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from httpware import AsyncClient
|
|
64
|
+
from pydantic import BaseModel
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class User(BaseModel):
|
|
68
|
+
id: int
|
|
69
|
+
name: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def main() -> None:
|
|
73
|
+
async with AsyncClient(base_url="https://api.example.com") as client:
|
|
74
|
+
user = await client.get("/users/1", response_model=User)
|
|
75
|
+
print(user.name)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## What ships in 0.1.0
|
|
79
|
+
|
|
80
|
+
- **`AsyncClient`** — eight HTTP method shortcuts (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `request`) with typed `response_model` overloads; per-call overrides for `headers`, `params`, `cookies`, `timeout`, `json`, `content`; httpx-style `base_url` join; `with_options(...)` returns a view sharing the same transport.
|
|
81
|
+
- **Transport-agnostic seam.** `httpx2` is confined to `httpware.transports.httpx2.Httpx2Transport`. Implement the `Transport` protocol to swap backends.
|
|
82
|
+
- **Middleware foundation.** `Middleware` protocol, `Next` type alias, recursive-closure `compose()` chain composition, and phase decorators (`@before_request`, `@after_response`, `@on_error`).
|
|
83
|
+
- **Pluggable response decoding.** `PydanticDecoder` (default) with cached `TypeAdapter`; `MsgspecDecoder` via `httpware[msgspec]`.
|
|
84
|
+
- **`RecordedTransport`** — built-in test double with a route table, observed-request list, and `aclose_calls` counter.
|
|
85
|
+
- **Status-keyed exception hierarchy** — `StatusError`, 4xx / 5xx subclasses, plain typed fields (`status: int`, `body: bytes`, `headers`, `json`, `request_method`, `request_url`). Pickleable; userinfo redacted in `__repr__`.
|
|
86
|
+
- **No `httpx2` exception types** leak through `httpware`. The transport seam maps them to `httpware` exceptions.
|
|
87
|
+
|
|
88
|
+
## Part of `modern-python`
|
|
89
|
+
|
|
90
|
+
Browse the full list of templates and libraries in [`modern-python`](https://github.com/modern-python) — see the org profile for the categorized index.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
httpware/__init__.py,sha256=ou3ZG7AJNUWby5VT8eUDIEVhWs__5eA_t6TteXsMSCg,1733
|
|
2
|
+
httpware/_internal/__init__.py,sha256=xRyN_CoQ1XZCzo2h15x2v5vZd3buM5n5MsMi4qGfj5o,65
|
|
3
|
+
httpware/_internal/chain.py,sha256=u5IUs-LLMfCIrMSYT3srHwY3AsJc2_5yFRnKx7gdYFE,1383
|
|
4
|
+
httpware/_internal/import_checker.py,sha256=XWgqP9TJO9O3TjYAMPsoAR8cLxRVmMSMCMuIxKCvzUY,195
|
|
5
|
+
httpware/client.py,sha256=c9dSj0IAU7zsOmtLLn2h22EGu1BVwMPaFampIoYwOU0,19829
|
|
6
|
+
httpware/config.py,sha256=MmtLbb8C8x3r3nAPWo9oyR843xeUqnMcJ2APjyMnB1A,1190
|
|
7
|
+
httpware/decoders/__init__.py,sha256=ZriYOpgse0cd9K_R4u0Bo8xKQdOcafhvcIdUmBa_sFA,470
|
|
8
|
+
httpware/decoders/msgspec.py,sha256=4ohALyeumX3DcosFEDVScZ_GtzdO7LaIzgPG0mhNhsE,1066
|
|
9
|
+
httpware/decoders/pydantic.py,sha256=fxwkc1_njsWG8yD6FbHJHLX1khy-jYq7GNsUpHnm0H0,756
|
|
10
|
+
httpware/errors.py,sha256=yvQ6Z1MujYhxguVI_Q26mim7xbAu2MKuEuDUXfm0gmo,6079
|
|
11
|
+
httpware/middleware/__init__.py,sha256=6jR0s_lKmd3dDaNg4vxbDDEUlAA06C6ypvDd9ZcXp_c,3262
|
|
12
|
+
httpware/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
httpware/request.py,sha256=LISPQH5FbxM5P3anycw0kFd8VxOA1fDjNfnjr8E1e3I,2458
|
|
14
|
+
httpware/response.py,sha256=VbavHF5ZCX3VnMXbQfoI3U91Qvqe263drGRKGV-NATI,1998
|
|
15
|
+
httpware/transports/__init__.py,sha256=tZ414G7aXs8MAHs5hqubRBmrrdF_naRlw5Jp89gBqro,831
|
|
16
|
+
httpware/transports/httpx2.py,sha256=gTMfM98_UOq2mK_ZV7k8DZJw8XjTldI2z_TmsezhQxg,7082
|
|
17
|
+
httpware/transports/recorded.py,sha256=RV_82s4bqTSXzYbfp7npWCKLwqT51UYWAq-1YVph63c,3302
|
|
18
|
+
httpware-0.1.0.dist-info/WHEEL,sha256=Q9FtwzuR2QE37l-JIkuyklGnJJiCBHKnsPVQ9vzCMzQ,81
|
|
19
|
+
httpware-0.1.0.dist-info/METADATA,sha256=EkZq_GMtftccfjoTxLK4tgdEnA5Xy1I9ZJbIrArIGzs,4430
|
|
20
|
+
httpware-0.1.0.dist-info/RECORD,,
|