mxhttp 1.0.0__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.
- mxhttp-1.0.0/LICENSE +21 -0
- mxhttp-1.0.0/PKG-INFO +202 -0
- mxhttp-1.0.0/README.md +178 -0
- mxhttp-1.0.0/mxhttp/__init__.py +36 -0
- mxhttp-1.0.0/mxhttp/consumer.py +95 -0
- mxhttp-1.0.0/mxhttp/endpoint.py +220 -0
- mxhttp-1.0.0/mxhttp/markers.py +200 -0
- mxhttp-1.0.0/mxhttp/py.typed +0 -0
- mxhttp-1.0.0/mxhttp/request.py +231 -0
- mxhttp-1.0.0/mxhttp/response.py +174 -0
- mxhttp-1.0.0/mxhttp/sse.py +60 -0
- mxhttp-1.0.0/mxhttp/types.py +79 -0
- mxhttp-1.0.0/pyproject.toml +208 -0
- mxhttp-1.0.0/tests/conftest.py +74 -0
- mxhttp-1.0.0/tests/models.py +388 -0
- mxhttp-1.0.0/tests/test_api.py +921 -0
- mxhttp-1.0.0/tests/test_decode_types.py +61 -0
- mxhttp-1.0.0/tests/test_type_errors.py +55 -0
- mxhttp-1.0.0/tests/test_type_hints.py +115 -0
mxhttp-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Hörmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mxhttp-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mxhttp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Simple HTTP API consumer based on `httpx` and `msgspec`.
|
|
5
|
+
Keywords: http,httpx,msgspec,rest,api,client,declarative,typing
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
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
|
+
Project-URL: Homepage, https://github.com/audivir/mxhttp
|
|
15
|
+
Project-URL: Repository, https://github.com/audivir/mxhttp
|
|
16
|
+
Project-URL: Issues, https://github.com/audivir/mxhttp/issues
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx
|
|
19
|
+
Requires-Dist: msgspec
|
|
20
|
+
Requires-Dist: typing-extensions
|
|
21
|
+
Provides-Extra: pydantic
|
|
22
|
+
Requires-Dist: pydantic; extra == "pydantic"
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# mxhttp
|
|
26
|
+
|
|
27
|
+
Declarative **HTTP** client on top of _**m**sgspec_ and _http**x**_. Write an API as a class of annotated stub methods and `mxhttp` will handle the rest (request building, sending, and response decoding).
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install mxhttp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from typing import Annotated
|
|
39
|
+
import msgspec
|
|
40
|
+
from mxhttp import Body, Query, SyncConsumer, get, post
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Item(msgspec.Struct):
|
|
44
|
+
id: int
|
|
45
|
+
name: str
|
|
46
|
+
price: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NewItem(msgspec.Struct):
|
|
50
|
+
name: str
|
|
51
|
+
price: float
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Shop(SyncConsumer):
|
|
55
|
+
@get("/items/{item_id}")
|
|
56
|
+
def get_item(self, item_id: int) -> Item: ... # type: ignore[empty-body]
|
|
57
|
+
|
|
58
|
+
@get("/search")
|
|
59
|
+
def search(self, q: Annotated[str, Query], limit: Annotated[int, Query] = 20) -> list[Item]: ... # type: ignore[empty-body]
|
|
60
|
+
|
|
61
|
+
@post("/items")
|
|
62
|
+
def create_item(self, item: Annotated[NewItem, Body]) -> Item: ... # type: ignore[empty-body]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
shop = Shop("https://api.example.com")
|
|
66
|
+
item = shop.get_item(item_id=7)
|
|
67
|
+
new = shop.create_item(item=NewItem(name="Gadget", price=4.5))
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The method body is never run as it is replaced by the decorator. Parameters are bound based on their `Annotated[...]` marker:
|
|
71
|
+
|
|
72
|
+
| Marker class | Request Target | Info |
|
|
73
|
+
|----------------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
74
|
+
| `Path` (or implicit) | Path | Matched by parameter name unless annotated explicitly (`Path["name"]`). Must be a non-nullable `str`, `int`, or `float`. |
|
|
75
|
+
| `Query` | Query | Must be a nullable `str`, `int`, `float`, or `bool`, or a `Sequence` of those, sent as `key=a&key=b&...`. |
|
|
76
|
+
| `Field` | Form Field | `application/x-www-form-urlencoded`. Accepts same types as `Query`. |
|
|
77
|
+
| `Part` | Multipart File Part | Forces the whole request to be multipart and any `Field` params on the same call will become multipart fields as well. Accepts the same types `httpx` takes for `files=`. |
|
|
78
|
+
| `Header` | HTTP Header | Must be `str`, `int`, `float`, or `bool`, but no list of those. |
|
|
79
|
+
| `Cookie` | Cookie | Is superseded by the cookie jar of the client if it already has a same-named cookie, unless `override=True` is set. Accepts same types as `Header` |
|
|
80
|
+
| `Body` | JSON Body | Whole object, serialized with `msgspec.to_builtins`. Can't be a scalar type. |
|
|
81
|
+
|
|
82
|
+
- Use `Path["name"]`, `Query["name"]`, `Field["name"]`, `Header["name"]`, or `Cookie["name"]` to bind under a different name than the parameter (e.g. reserved `from`, or a header like `X-Request-Id`, unsupported string format arguments like `?`).
|
|
83
|
+
- `None`-valued `Query`, `Field`, `Header`, and `Cookie` parameters are omitted from the request.
|
|
84
|
+
- `Path` parameters cannot be optional as a placeholder cannot be ommited from the URL.
|
|
85
|
+
- Mismatched marker/type combinations raise a `TypeError` as soon as the class body runs, not at call time.
|
|
86
|
+
|
|
87
|
+
### Decoding the response
|
|
88
|
+
|
|
89
|
+
The return type defines the reponse decoding:
|
|
90
|
+
- `httpx.Response` for the raw response.
|
|
91
|
+
- `str` or `bytes` for the corresponding `.text` or `.content` with no JSON round-trip.
|
|
92
|
+
- `pydantic.BaseModel` subclasses via their own `.model_validate_json`.
|
|
93
|
+
- Anything else `msgspec.json.decode` can decode: `msgspec.Struct`, dataclasses, `TypedDict`, `NamedTuple`, and `list`, `dict`, or other containers of those.
|
|
94
|
+
- `Response[Item]` for a small struct with the decoded `Item` as `.data` and the raw `httpx.Response` in `.response`.
|
|
95
|
+
- Plain `attrs` classes are decoded by `msgspec`, for type hinting `attrs` is needed as dependency.
|
|
96
|
+
|
|
97
|
+
For an async client, subclass `AsyncConsumer` and declare the methods `async def`, everything else stays the same.
|
|
98
|
+
|
|
99
|
+
### Response handling
|
|
100
|
+
|
|
101
|
+
By default, every response is checked by `response.raise_for_status()` before decoding, so errors during the request raise `httpx.HTTPStatusError` automatically. This behavior can be overriden by `@response_handler` decorator for the class.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import httpx
|
|
105
|
+
from mxhttp import response_handler
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def ignore_errors(response: httpx.Response) -> httpx.Response:
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@response_handler(ignore_errors)
|
|
113
|
+
class Shop(SyncConsumer): ...
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The hook runs on every response before decoding.
|
|
117
|
+
|
|
118
|
+
### Streaming responses
|
|
119
|
+
|
|
120
|
+
Annotate the return type as `Iterator[bytes]` (sync) or `AsyncIterator[bytes]` (async) to stream the response body in chunks.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from collections.abc import AsyncIterator, Iterator
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Files(SyncConsumer):
|
|
127
|
+
@get("/files/{file_id}")
|
|
128
|
+
def download(self, file_id: int) -> Iterator[bytes]: ... # type: ignore[empty-body]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
for chunk in shop_files.download(file_id=7):
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
class AsyncFiles(AsyncConsumer)
|
|
135
|
+
@get("/files/{file_id}")
|
|
136
|
+
def download(self, file_id: int) -> AsyncIterator[bytes]: ... # type: ignore[empty-body]
|
|
137
|
+
|
|
138
|
+
async for chunk in await shop_async_files.download(file_id=7):
|
|
139
|
+
...
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`httpx` already decompresses chunks before responding according to `Content-Encoding` (gzip/deflate/br/zstd).
|
|
143
|
+
|
|
144
|
+
Streaming responses run `@streaming_response_handler` instead of `@response_handler` (defaults to `raise_for_status` as well).
|
|
145
|
+
The handler can only inspect status line and headers.
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from mxhttp import streaming_response_handler
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check_status(response: httpx.Response) -> httpx.Response:
|
|
152
|
+
response.raise_for_status()
|
|
153
|
+
return response
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@streaming_response_handler(check_status)
|
|
157
|
+
class Files(SyncConsumer): ...
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Server-Sent Events
|
|
161
|
+
|
|
162
|
+
Annotate the return type as `Iterator[Event]` (sync) or `AsyncIterator[Event]` (async) to parse the response as a Server-Sent Events stream instead of raw bytes:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from collections.abc import Iterator
|
|
166
|
+
from mxhttp import Event
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class Chat(SyncConsumer):
|
|
170
|
+
@get("/stream")
|
|
171
|
+
def events(self) -> Iterator[Event]: ... # type: ignore[empty-body]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
for event in chat.events():
|
|
175
|
+
print(event.event, event.data) # event.event defaults to "message"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`Event` has four attributes, `data`, `event`, `id`, and `retry`:
|
|
179
|
+
- `data` is the raw payload, decode it manually if the server sends JSON.
|
|
180
|
+
- Multi-line `data` fields are joined with `\n`.
|
|
181
|
+
- `id` and `retry` persist across events once set and reset on reconnect only.
|
|
182
|
+
- An event without a trailing blank line at the end of the stream is discarded.
|
|
183
|
+
|
|
184
|
+
SSE streams use `@streaming_response_handler` matching byte streaming above.
|
|
185
|
+
|
|
186
|
+
## Further configuration
|
|
187
|
+
|
|
188
|
+
The underlying `httpx.Client` or `httpx.AsyncClient` is stored at `.session` to set default headers, auth, or timeouts.
|
|
189
|
+
|
|
190
|
+
## Typing
|
|
191
|
+
|
|
192
|
+
The package and all its generators are typed.
|
|
193
|
+
|
|
194
|
+
## Tests
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
pytest
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Acknowledgements
|
|
201
|
+
|
|
202
|
+
`mxhttp` is inspired by [Uplink](https://github.com/prkumar/uplink) but combining it with Python typing features.
|
mxhttp-1.0.0/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# mxhttp
|
|
2
|
+
|
|
3
|
+
Declarative **HTTP** client on top of _**m**sgspec_ and _http**x**_. Write an API as a class of annotated stub methods and `mxhttp` will handle the rest (request building, sending, and response decoding).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install mxhttp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from typing import Annotated
|
|
15
|
+
import msgspec
|
|
16
|
+
from mxhttp import Body, Query, SyncConsumer, get, post
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Item(msgspec.Struct):
|
|
20
|
+
id: int
|
|
21
|
+
name: str
|
|
22
|
+
price: float
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NewItem(msgspec.Struct):
|
|
26
|
+
name: str
|
|
27
|
+
price: float
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Shop(SyncConsumer):
|
|
31
|
+
@get("/items/{item_id}")
|
|
32
|
+
def get_item(self, item_id: int) -> Item: ... # type: ignore[empty-body]
|
|
33
|
+
|
|
34
|
+
@get("/search")
|
|
35
|
+
def search(self, q: Annotated[str, Query], limit: Annotated[int, Query] = 20) -> list[Item]: ... # type: ignore[empty-body]
|
|
36
|
+
|
|
37
|
+
@post("/items")
|
|
38
|
+
def create_item(self, item: Annotated[NewItem, Body]) -> Item: ... # type: ignore[empty-body]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
shop = Shop("https://api.example.com")
|
|
42
|
+
item = shop.get_item(item_id=7)
|
|
43
|
+
new = shop.create_item(item=NewItem(name="Gadget", price=4.5))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The method body is never run as it is replaced by the decorator. Parameters are bound based on their `Annotated[...]` marker:
|
|
47
|
+
|
|
48
|
+
| Marker class | Request Target | Info |
|
|
49
|
+
|----------------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
50
|
+
| `Path` (or implicit) | Path | Matched by parameter name unless annotated explicitly (`Path["name"]`). Must be a non-nullable `str`, `int`, or `float`. |
|
|
51
|
+
| `Query` | Query | Must be a nullable `str`, `int`, `float`, or `bool`, or a `Sequence` of those, sent as `key=a&key=b&...`. |
|
|
52
|
+
| `Field` | Form Field | `application/x-www-form-urlencoded`. Accepts same types as `Query`. |
|
|
53
|
+
| `Part` | Multipart File Part | Forces the whole request to be multipart and any `Field` params on the same call will become multipart fields as well. Accepts the same types `httpx` takes for `files=`. |
|
|
54
|
+
| `Header` | HTTP Header | Must be `str`, `int`, `float`, or `bool`, but no list of those. |
|
|
55
|
+
| `Cookie` | Cookie | Is superseded by the cookie jar of the client if it already has a same-named cookie, unless `override=True` is set. Accepts same types as `Header` |
|
|
56
|
+
| `Body` | JSON Body | Whole object, serialized with `msgspec.to_builtins`. Can't be a scalar type. |
|
|
57
|
+
|
|
58
|
+
- Use `Path["name"]`, `Query["name"]`, `Field["name"]`, `Header["name"]`, or `Cookie["name"]` to bind under a different name than the parameter (e.g. reserved `from`, or a header like `X-Request-Id`, unsupported string format arguments like `?`).
|
|
59
|
+
- `None`-valued `Query`, `Field`, `Header`, and `Cookie` parameters are omitted from the request.
|
|
60
|
+
- `Path` parameters cannot be optional as a placeholder cannot be ommited from the URL.
|
|
61
|
+
- Mismatched marker/type combinations raise a `TypeError` as soon as the class body runs, not at call time.
|
|
62
|
+
|
|
63
|
+
### Decoding the response
|
|
64
|
+
|
|
65
|
+
The return type defines the reponse decoding:
|
|
66
|
+
- `httpx.Response` for the raw response.
|
|
67
|
+
- `str` or `bytes` for the corresponding `.text` or `.content` with no JSON round-trip.
|
|
68
|
+
- `pydantic.BaseModel` subclasses via their own `.model_validate_json`.
|
|
69
|
+
- Anything else `msgspec.json.decode` can decode: `msgspec.Struct`, dataclasses, `TypedDict`, `NamedTuple`, and `list`, `dict`, or other containers of those.
|
|
70
|
+
- `Response[Item]` for a small struct with the decoded `Item` as `.data` and the raw `httpx.Response` in `.response`.
|
|
71
|
+
- Plain `attrs` classes are decoded by `msgspec`, for type hinting `attrs` is needed as dependency.
|
|
72
|
+
|
|
73
|
+
For an async client, subclass `AsyncConsumer` and declare the methods `async def`, everything else stays the same.
|
|
74
|
+
|
|
75
|
+
### Response handling
|
|
76
|
+
|
|
77
|
+
By default, every response is checked by `response.raise_for_status()` before decoding, so errors during the request raise `httpx.HTTPStatusError` automatically. This behavior can be overriden by `@response_handler` decorator for the class.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import httpx
|
|
81
|
+
from mxhttp import response_handler
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ignore_errors(response: httpx.Response) -> httpx.Response:
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@response_handler(ignore_errors)
|
|
89
|
+
class Shop(SyncConsumer): ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The hook runs on every response before decoding.
|
|
93
|
+
|
|
94
|
+
### Streaming responses
|
|
95
|
+
|
|
96
|
+
Annotate the return type as `Iterator[bytes]` (sync) or `AsyncIterator[bytes]` (async) to stream the response body in chunks.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from collections.abc import AsyncIterator, Iterator
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Files(SyncConsumer):
|
|
103
|
+
@get("/files/{file_id}")
|
|
104
|
+
def download(self, file_id: int) -> Iterator[bytes]: ... # type: ignore[empty-body]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
for chunk in shop_files.download(file_id=7):
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
class AsyncFiles(AsyncConsumer)
|
|
111
|
+
@get("/files/{file_id}")
|
|
112
|
+
def download(self, file_id: int) -> AsyncIterator[bytes]: ... # type: ignore[empty-body]
|
|
113
|
+
|
|
114
|
+
async for chunk in await shop_async_files.download(file_id=7):
|
|
115
|
+
...
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`httpx` already decompresses chunks before responding according to `Content-Encoding` (gzip/deflate/br/zstd).
|
|
119
|
+
|
|
120
|
+
Streaming responses run `@streaming_response_handler` instead of `@response_handler` (defaults to `raise_for_status` as well).
|
|
121
|
+
The handler can only inspect status line and headers.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from mxhttp import streaming_response_handler
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_status(response: httpx.Response) -> httpx.Response:
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
return response
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@streaming_response_handler(check_status)
|
|
133
|
+
class Files(SyncConsumer): ...
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Server-Sent Events
|
|
137
|
+
|
|
138
|
+
Annotate the return type as `Iterator[Event]` (sync) or `AsyncIterator[Event]` (async) to parse the response as a Server-Sent Events stream instead of raw bytes:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from collections.abc import Iterator
|
|
142
|
+
from mxhttp import Event
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Chat(SyncConsumer):
|
|
146
|
+
@get("/stream")
|
|
147
|
+
def events(self) -> Iterator[Event]: ... # type: ignore[empty-body]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
for event in chat.events():
|
|
151
|
+
print(event.event, event.data) # event.event defaults to "message"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`Event` has four attributes, `data`, `event`, `id`, and `retry`:
|
|
155
|
+
- `data` is the raw payload, decode it manually if the server sends JSON.
|
|
156
|
+
- Multi-line `data` fields are joined with `\n`.
|
|
157
|
+
- `id` and `retry` persist across events once set and reset on reconnect only.
|
|
158
|
+
- An event without a trailing blank line at the end of the stream is discarded.
|
|
159
|
+
|
|
160
|
+
SSE streams use `@streaming_response_handler` matching byte streaming above.
|
|
161
|
+
|
|
162
|
+
## Further configuration
|
|
163
|
+
|
|
164
|
+
The underlying `httpx.Client` or `httpx.AsyncClient` is stored at `.session` to set default headers, auth, or timeouts.
|
|
165
|
+
|
|
166
|
+
## Typing
|
|
167
|
+
|
|
168
|
+
The package and all its generators are typed.
|
|
169
|
+
|
|
170
|
+
## Tests
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
pytest
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Acknowledgements
|
|
177
|
+
|
|
178
|
+
`mxhttp` is inspired by [Uplink](https://github.com/prkumar/uplink) but combining it with Python typing features.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Minimal typed declarative HTTP client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mxhttp.consumer import AsyncConsumer, SyncConsumer
|
|
6
|
+
from mxhttp.endpoint import delete, endpoint, get, head, patch, post, put
|
|
7
|
+
from mxhttp.markers import Body, Cookie, Field, Header, Part, Path, Query
|
|
8
|
+
from mxhttp.response import Response, response_handler, streaming_response_handler
|
|
9
|
+
from mxhttp.sse import Event
|
|
10
|
+
from mxhttp.types import PartValue
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncConsumer",
|
|
16
|
+
"Body",
|
|
17
|
+
"Cookie",
|
|
18
|
+
"Event",
|
|
19
|
+
"Field",
|
|
20
|
+
"Header",
|
|
21
|
+
"Part",
|
|
22
|
+
"PartValue",
|
|
23
|
+
"Path",
|
|
24
|
+
"Query",
|
|
25
|
+
"Response",
|
|
26
|
+
"SyncConsumer",
|
|
27
|
+
"delete",
|
|
28
|
+
"endpoint",
|
|
29
|
+
"get",
|
|
30
|
+
"head",
|
|
31
|
+
"patch",
|
|
32
|
+
"post",
|
|
33
|
+
"put",
|
|
34
|
+
"response_handler",
|
|
35
|
+
"streaming_response_handler",
|
|
36
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Base classes for the declarative HTTP consumers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Self, override
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from mxhttp.types import ResponseHandler
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseConsumer:
|
|
18
|
+
"""Base class for the sync and async declarative API client."""
|
|
19
|
+
|
|
20
|
+
_response_handler: ResponseHandler | None = None
|
|
21
|
+
_streaming_response_handler: ResponseHandler | None = None
|
|
22
|
+
|
|
23
|
+
def __init__(self, base_url: str, *, use_async: bool = False) -> None:
|
|
24
|
+
"""Initializes the client bound to `base_url` with an empty header dict."""
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
self.base_url = base_url.rstrip("/")
|
|
28
|
+
self._session = (
|
|
29
|
+
httpx.AsyncClient(base_url=self.base_url)
|
|
30
|
+
if use_async
|
|
31
|
+
else httpx.Client(base_url=self.base_url)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
def __repr__(self) -> str: # pragma: no cover
|
|
36
|
+
return f"{type(self).__name__}<{self.base_url}>"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def session(self) -> httpx.Client | httpx.AsyncClient:
|
|
40
|
+
"""The HTTP client used for outbound requests."""
|
|
41
|
+
return self._session
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SyncConsumer(BaseConsumer):
|
|
45
|
+
"""Base class for the synchronous declarative API client."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, base_url: str) -> None:
|
|
48
|
+
"""Initializes the client bound to `base_url` with an empty header dict."""
|
|
49
|
+
super().__init__(base_url, use_async=False)
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> Self:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(
|
|
55
|
+
self,
|
|
56
|
+
exc_type: type[BaseException] | None,
|
|
57
|
+
exc_val: BaseException | None,
|
|
58
|
+
exc_tb: TracebackType | None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.session.close()
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
|
|
64
|
+
@override
|
|
65
|
+
@property
|
|
66
|
+
def session(self) -> httpx.Client:
|
|
67
|
+
"""The synchronous HTTP client."""
|
|
68
|
+
return self._session # type: ignore[return-value]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AsyncConsumer(BaseConsumer):
|
|
72
|
+
"""Base class for the asynchronous declarative API client."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, base_url: str) -> None:
|
|
75
|
+
"""Initializes the client bound to `base_url` with an empty header dict."""
|
|
76
|
+
super().__init__(base_url, use_async=True)
|
|
77
|
+
|
|
78
|
+
async def __aenter__(self) -> Self:
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
async def __aexit__(
|
|
82
|
+
self,
|
|
83
|
+
exc_type: type[BaseException] | None,
|
|
84
|
+
exc_val: BaseException | None,
|
|
85
|
+
exc_tb: TracebackType | None,
|
|
86
|
+
) -> None:
|
|
87
|
+
await self.session.aclose()
|
|
88
|
+
|
|
89
|
+
if TYPE_CHECKING:
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
@property
|
|
93
|
+
def session(self) -> httpx.AsyncClient:
|
|
94
|
+
"""The asynchronous HTTP client."""
|
|
95
|
+
return self._session # type: ignore[return-value]
|