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 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]