mxhttp 1.0.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.
mxhttp/__init__.py ADDED
@@ -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
+ ]
mxhttp/consumer.py ADDED
@@ -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]
mxhttp/endpoint.py ADDED
@@ -0,0 +1,220 @@
1
+ """Creates the endpoint wrapped function."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import inspect
7
+ from collections.abc import AsyncIterator, Callable, Coroutine, Iterator
8
+ from typing import (
9
+ TYPE_CHECKING,
10
+ Any,
11
+ Concatenate,
12
+ ParamSpec,
13
+ Protocol,
14
+ get_args,
15
+ get_origin,
16
+ overload,
17
+ )
18
+
19
+ from mxhttp.request import RequestSpec, build_plan, build_request
20
+ from mxhttp.response import (
21
+ apply_response_handler,
22
+ decode,
23
+ sse_async,
24
+ sse_sync,
25
+ stream_async,
26
+ stream_sync,
27
+ )
28
+ from mxhttp.sse import Event
29
+
30
+ if TYPE_CHECKING:
31
+ from mxhttp import AsyncConsumer, SyncConsumer
32
+ from mxhttp.consumer import BaseConsumer
33
+ from mxhttp.types import AnyC_T, AsyncC_T, Method_T, Parsed_T, SyncC_T
34
+
35
+ P = ParamSpec("P")
36
+
37
+
38
+ class EndpointDecorator(Protocol):
39
+ """Interface for sync/async return invariants based on the context type."""
40
+
41
+ @overload
42
+ def __call__(
43
+ self, func: Callable[Concatenate[SyncC_T, P], Parsed_T]
44
+ ) -> Callable[Concatenate[SyncC_T, P], Parsed_T]: ...
45
+
46
+ @overload
47
+ def __call__(
48
+ self,
49
+ func: Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, Parsed_T]],
50
+ ) -> Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, Parsed_T]]: ...
51
+
52
+ @overload
53
+ def __call__(
54
+ self, func: Callable[Concatenate[SyncC_T, P], None]
55
+ ) -> Callable[Concatenate[SyncC_T, P], None]: ...
56
+
57
+ @overload
58
+ def __call__(
59
+ self,
60
+ func: Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, None]],
61
+ ) -> Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, None]]: ...
62
+
63
+ @overload
64
+ def __call__(
65
+ self, func: Callable[Concatenate[SyncC_T, P], Iterator[bytes]]
66
+ ) -> Callable[Concatenate[SyncC_T, P], Iterator[bytes]]: ...
67
+
68
+ @overload
69
+ def __call__(
70
+ self,
71
+ func: Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, AsyncIterator[bytes]]],
72
+ ) -> Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, AsyncIterator[bytes]]]: ...
73
+
74
+ @overload
75
+ def __call__(
76
+ self, func: Callable[Concatenate[SyncC_T, P], Iterator[Event]]
77
+ ) -> Callable[Concatenate[SyncC_T, P], Iterator[Event]]: ...
78
+
79
+ @overload
80
+ def __call__(
81
+ self,
82
+ func: Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, AsyncIterator[Event]]],
83
+ ) -> Callable[Concatenate[AsyncC_T, P], Coroutine[Any, Any, AsyncIterator[Event]]]: ...
84
+
85
+
86
+ def endpoint(method: Method_T, path: str) -> EndpointDecorator: # noqa: C901
87
+ """Shared implementation for the HTTP method decorator factories."""
88
+
89
+ def decorate( # noqa: C901
90
+ func: Callable[Concatenate[AnyC_T, P], Parsed_T]
91
+ | Callable[Concatenate[AnyC_T, P], Coroutine[Any, Any, Parsed_T]],
92
+ ) -> (
93
+ Callable[Concatenate[AnyC_T, P], Parsed_T]
94
+ | Callable[Concatenate[AnyC_T, P], Coroutine[Any, Any, Parsed_T]]
95
+ ):
96
+ plan, return_type = build_plan(func, path)
97
+ sig = inspect.signature(func)
98
+ origin = get_origin(return_type)
99
+ stream_item = get_args(return_type)[0] if origin in (Iterator, AsyncIterator) else None
100
+ is_raw_stream = stream_item is bytes
101
+ is_sse_stream = stream_item is Event
102
+ has_cookies = any(p.kind == "cookie" for p in plan)
103
+
104
+ def resolve_spec(self: BaseConsumer, *args: P.args, **kwargs: P.kwargs) -> RequestSpec:
105
+ """Binds call arguments against the stub signature and builds the request spec."""
106
+ bound = sig.bind(self, *args, **kwargs)
107
+ bound.apply_defaults()
108
+ jar = dict(self.session.cookies) if has_cookies else None
109
+ return build_request(method, path, plan, bound.arguments, jar=jar)
110
+
111
+ if inspect.iscoroutinefunction(func):
112
+ if is_sse_stream:
113
+
114
+ @functools.wraps(func)
115
+ async def async_sse_wrapper(
116
+ self: AsyncConsumer, *args: P.args, **kwargs: P.kwargs
117
+ ) -> AsyncIterator[Event]:
118
+ spec = resolve_spec(self, *args, **kwargs)
119
+ return sse_async(self, spec)
120
+
121
+ return async_sse_wrapper # type: ignore[return-value]
122
+
123
+ if is_raw_stream:
124
+
125
+ @functools.wraps(func)
126
+ async def async_stream_wrapper(
127
+ self: AsyncConsumer, *args: P.args, **kwargs: P.kwargs
128
+ ) -> AsyncIterator[bytes]:
129
+ spec = resolve_spec(self, *args, **kwargs)
130
+ return stream_async(self, spec)
131
+
132
+ return async_stream_wrapper # type: ignore[return-value]
133
+
134
+ @functools.wraps(func)
135
+ async def async_wrapper(
136
+ self: AsyncConsumer, *args: P.args, **kwargs: P.kwargs
137
+ ) -> Parsed_T:
138
+ spec = resolve_spec(self, *args, **kwargs)
139
+ response = await self.session.request(spec.method, spec.url, **spec.to_kwargs())
140
+ return decode(apply_response_handler(self, response), return_type)
141
+
142
+ return async_wrapper # type: ignore[return-value]
143
+
144
+ if is_sse_stream:
145
+
146
+ @functools.wraps(func)
147
+ def sync_sse_wrapper(
148
+ self: SyncConsumer, *args: P.args, **kwargs: P.kwargs
149
+ ) -> Iterator[Event]:
150
+ spec = resolve_spec(self, *args, **kwargs)
151
+ return sse_sync(self, spec)
152
+
153
+ return sync_sse_wrapper # type: ignore[return-value]
154
+
155
+ if is_raw_stream:
156
+
157
+ @functools.wraps(func)
158
+ def sync_stream_wrapper(
159
+ self: SyncConsumer, *args: P.args, **kwargs: P.kwargs
160
+ ) -> Iterator[bytes]:
161
+ spec = resolve_spec(self, *args, **kwargs)
162
+ return stream_sync(self, spec)
163
+
164
+ return sync_stream_wrapper # type: ignore[return-value]
165
+
166
+ @functools.wraps(func)
167
+ def sync_wrapper(
168
+ self: SyncConsumer,
169
+ *args: P.args,
170
+ **kwargs: P.kwargs,
171
+ ) -> Parsed_T:
172
+ spec = resolve_spec(self, *args, **kwargs)
173
+ response = self.session.request(spec.method, spec.url, **spec.to_kwargs())
174
+ return decode(apply_response_handler(self, response), return_type)
175
+
176
+ return sync_wrapper # type: ignore[return-value]
177
+
178
+ return decorate # type: ignore[return-value]
179
+
180
+
181
+ def get(
182
+ path: str,
183
+ ) -> EndpointDecorator:
184
+ """Declares a stub method as `GET {path}`."""
185
+ return endpoint("GET", path)
186
+
187
+
188
+ def post(
189
+ path: str,
190
+ ) -> EndpointDecorator:
191
+ """Declares a stub method as `POST {path}`."""
192
+ return endpoint("POST", path)
193
+
194
+
195
+ def put(
196
+ path: str,
197
+ ) -> EndpointDecorator:
198
+ """Declares a stub method as `PUT {path}`."""
199
+ return endpoint("PUT", path)
200
+
201
+
202
+ def patch(
203
+ path: str,
204
+ ) -> EndpointDecorator:
205
+ """Declares a stub method as `PATCH {path}`."""
206
+ return endpoint("PATCH", path)
207
+
208
+
209
+ def delete(
210
+ path: str,
211
+ ) -> EndpointDecorator:
212
+ """Declares a stub method as `DELETE {path}`."""
213
+ return endpoint("DELETE", path)
214
+
215
+
216
+ def head(
217
+ path: str,
218
+ ) -> EndpointDecorator:
219
+ """Declares a stub method as `HEAD {path}`."""
220
+ return endpoint("HEAD", path)
mxhttp/markers.py ADDED
@@ -0,0 +1,200 @@
1
+ """Markers for HTTP request parameters inside `Annotated[...]`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from types import UnionType
7
+ from typing import TYPE_CHECKING, Union, get_args, get_origin
8
+
9
+ from typing_extensions import Self
10
+
11
+ from mxhttp.types import ParamValue, ValidPath_T
12
+
13
+ if TYPE_CHECKING:
14
+ from inspect import Parameter
15
+
16
+
17
+ def is_scalar_sequence(hint: type | None) -> bool:
18
+ """Checks whether `hint` is a `list`, `tuple`, or `Sequence` of scalar `ParamValue` objects."""
19
+ if get_origin(hint) not in (list, tuple, Sequence):
20
+ return False
21
+ item_types = [a for a in get_args(hint) if a is not Ellipsis]
22
+ return bool(item_types) and all(
23
+ isinstance(a, type) and issubclass(a, ParamValue) for a in item_types
24
+ )
25
+
26
+
27
+ def validate_scalar_arg(
28
+ py_name: str, scalar_type: type | None, marker_name: str, *, allow_sequence: bool = False
29
+ ) -> None:
30
+ """Verifies that `Query`,`Field`, `Header`, or `Cookie` arguments have a scalar type."""
31
+ if allow_sequence and is_scalar_sequence(scalar_type):
32
+ return
33
+ if not isinstance(scalar_type, type) or not issubclass(scalar_type, ParamValue):
34
+ allowed = "str | int | float | bool"
35
+ if allow_sequence:
36
+ allowed += " | Sequence[str | int | float | bool]"
37
+ raise TypeError(f"{marker_name} argument {py_name!r} must be {allowed}")
38
+
39
+
40
+ def is_file_content(hint: type | None) -> bool:
41
+ """Checks whether `hint` is bytes/str/file-like, or a `Union` of those, like `FileContent`."""
42
+ if get_origin(hint) in (Union, UnionType):
43
+ return all(is_file_content(a) for a in get_args(hint))
44
+ return hint in (bytes, str) or callable(getattr(hint, "read", None))
45
+
46
+
47
+ def unwrap_optional(hint: type | None) -> tuple[type | None, bool]:
48
+ """Removes the `Optional` wrapper from a `None`-defaulted parameter annotation."""
49
+ if get_origin(hint) not in (UnionType, Union):
50
+ return hint, hint is None
51
+ args = get_args(hint)
52
+ non_none = [a for a in args if a is not type(None)]
53
+ return non_none[0] if len(non_none) == 1 else hint, len(args) > len(non_none)
54
+
55
+
56
+ def is_optional_str(hint: type | None) -> bool:
57
+ """Checks whether `hint` is `str` or `str | None`."""
58
+ resolved, _ = unwrap_optional(hint)
59
+ return resolved is str
60
+
61
+
62
+ def is_part_tuple(hint: type | None) -> bool: # noqa: PLR0911
63
+ """Checks whether `hint` is a multipart file part tuple shape."""
64
+ if get_origin(hint) is not tuple:
65
+ return False
66
+ args = get_args(hint)
67
+ if len(args) < 2: # noqa: PLR2004
68
+ return False
69
+ filename, content, *rest = args
70
+ if not is_optional_str(filename) or not is_file_content(content):
71
+ return False
72
+ match rest:
73
+ case []:
74
+ return True
75
+ case [content_type]:
76
+ return is_optional_str(content_type)
77
+ case [content_type, headers]:
78
+ return is_optional_str(content_type) and get_origin(headers) in (Mapping, dict)
79
+ case _:
80
+ return False
81
+
82
+
83
+ class Marker:
84
+ """Base class for parameter-binding annotations inside `Annotated[...]`."""
85
+
86
+ __slots__ = ("name",)
87
+
88
+ def __init__(self, name: str | None = None) -> None:
89
+ """Initializes the tracker with an optional name."""
90
+ self.name = name
91
+
92
+ def __class_getitem__(cls, name: str) -> Self:
93
+ return cls(name)
94
+
95
+
96
+ class Path(Marker):
97
+ """Binds a parameter to an URL path parameter."""
98
+
99
+ @staticmethod
100
+ def validate(py_name: str, path_type: type | None, is_optional: bool, param: Parameter) -> None:
101
+ """Verifies that path arguments have the correct type."""
102
+ if (
103
+ not isinstance(path_type, type)
104
+ or issubclass(path_type, bool)
105
+ or not issubclass(path_type, ValidPath_T)
106
+ ):
107
+ raise TypeError(f"Path argument {py_name!r} must be str | int | float")
108
+ if param.default is None:
109
+ raise TypeError(f"Path argument {py_name!r} must not default to None")
110
+ if is_optional:
111
+ raise TypeError(f"Path argument {py_name!r} must not be optional")
112
+
113
+
114
+ class Query(Marker):
115
+ """Binds a parameter to an URL query string parameter, omitting `None` values."""
116
+
117
+ @classmethod
118
+ def validate(cls, name: str, resolved_hint: type | None) -> None:
119
+ """Verifies that a `Query` argument is a scalar and or a sequence of scalars."""
120
+ validate_scalar_arg(name, resolved_hint, cls.__name__, allow_sequence=True)
121
+
122
+
123
+ class Field(Marker):
124
+ """Binds a parameter to a form field, omitting `None` values.
125
+
126
+ Encoded as `application/x-www-form-urlencoded`, unless the same call also has a `Part`
127
+ parameter, in which case the whole body becomes multipart instead.
128
+ """
129
+
130
+ @classmethod
131
+ def validate(cls, name: str, resolved_hint: type | None) -> None:
132
+ """Verifies that a `Field` argument is a scalar and or a sequence of scalars."""
133
+ validate_scalar_arg(name, resolved_hint, cls.__name__, allow_sequence=True)
134
+
135
+
136
+ class Part(Marker):
137
+ """Binds a parameter to a multipart file part.
138
+
139
+ Converts the request body to multipart format, making any `Field` parameters on the
140
+ same call multipart fields instead of urlencoded.
141
+ """
142
+
143
+ @staticmethod
144
+ def validate(py_name: str, part_type: type | None) -> None:
145
+ """Verifies that a `Part` argument matches one of `httpx`'s accepted file-upload shapes.
146
+
147
+ `part_type` may be a `Union` of accepted shapes (e.g. the `PartValue` alias), so every
148
+ union member must be individually valid.
149
+ """
150
+ candidates = (
151
+ get_args(part_type) if get_origin(part_type) in (Union, UnionType) else [part_type]
152
+ )
153
+ if candidates and all(is_file_content(c) or is_part_tuple(c) for c in candidates):
154
+ return
155
+ raise TypeError(
156
+ f"Part argument {py_name!r} must be bytes | str | IO, optionally wrapped in a "
157
+ "(filename, content), (filename, content, content_type), or "
158
+ "(filename, content, content_type, headers) tuple"
159
+ )
160
+
161
+
162
+ class Header(Marker):
163
+ """Binds a parameter to an HTTP request header and omits `None` values."""
164
+
165
+ @classmethod
166
+ def validate(cls, name: str, resolved_hint: type | None) -> None:
167
+ """Verifies that a `Header` argument is a scalar and not a sequence."""
168
+ validate_scalar_arg(name, resolved_hint, cls.__name__)
169
+
170
+
171
+ class Cookie(Marker):
172
+ """Binds a parameter to a request cookie, omitting `None` values.
173
+
174
+ If the cookie jar already holds a cookie with this name, the jar value is sent
175
+ instead of this parameter value, unless `override=True` is set.
176
+ """
177
+
178
+ __slots__ = ("override",)
179
+
180
+ def __init__(self, name: str | None = None, *, override: bool = False) -> None:
181
+ """Initializes the cookie binding with an optional name and jar-override flag."""
182
+ super().__init__(name)
183
+ self.override = override
184
+
185
+ @classmethod
186
+ def validate(cls, name: str, resolved_hint: type | None) -> None:
187
+ """Verifies that a `Cookie` argument is a scalar and not a sequence."""
188
+ validate_scalar_arg(name, resolved_hint, cls.__name__)
189
+
190
+
191
+ class Body:
192
+ """Binds a whole JSON-encodable parameter as the JSON request body."""
193
+
194
+ @staticmethod
195
+ def validate(py_name: str, body_type: type | None) -> None:
196
+ """Verifies that a `Body` argument is a object, not just a scalar."""
197
+ if isinstance(body_type, type) and issubclass(body_type, (*get_args(ParamValue), bytes)):
198
+ raise TypeError(
199
+ f"Body argument {py_name!r} must not be str | int | float | bool | bytes"
200
+ )
mxhttp/py.typed ADDED
File without changes
mxhttp/request.py ADDED
@@ -0,0 +1,231 @@
1
+ """Builds the `httpx.Request`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import string
7
+ import urllib.parse
8
+ from types import UnionType
9
+ from typing import (
10
+ TYPE_CHECKING,
11
+ Annotated,
12
+ Any,
13
+ Concatenate,
14
+ ParamSpec,
15
+ TypedDict,
16
+ Union,
17
+ get_args,
18
+ get_origin,
19
+ get_type_hints,
20
+ )
21
+
22
+ import msgspec
23
+
24
+ from mxhttp.markers import Body, Cookie, Field, Header, Marker, Part, Path, Query, unwrap_optional
25
+ from mxhttp.response import Response
26
+ from mxhttp.types import MISSING, AnyC_T, JsonValue, Param_T, Parsed_T, PartValue, QueryValue
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import Callable, Collection, Coroutine, Mapping
30
+ from inspect import Parameter
31
+ P = ParamSpec("P")
32
+
33
+
34
+ class ParamPlan(msgspec.Struct):
35
+ """Stores the binding location for a single bound parameter in a request."""
36
+
37
+ py_name: str
38
+ wire_name: str
39
+ kind: Param_T
40
+ cookie_override: bool = False
41
+
42
+
43
+ class RequestKwargs(TypedDict):
44
+ """Stores keyword arguments shared by every `session.request` or `session.stream` call."""
45
+
46
+ params: dict[str, QueryValue]
47
+ headers: dict[str, str] | None
48
+ data: dict[str, object] | None
49
+ files: dict[str, PartValue] | None
50
+ json: JsonValue
51
+
52
+
53
+ class RequestSpec(msgspec.Struct):
54
+ """Stores a fully resolved request, ready to pass to `httpx`."""
55
+
56
+ method: str
57
+ url: str
58
+ params: dict[str, QueryValue]
59
+ headers: dict[str, str] | None
60
+ data: dict[str, object] | None
61
+ files: dict[str, PartValue] | None
62
+ json: JsonValue
63
+
64
+ def to_kwargs(self) -> RequestKwargs:
65
+ """Builds the keyword arguments shared by every `session.request`/`session.stream` call."""
66
+ return {
67
+ "params": self.params,
68
+ "headers": self.headers,
69
+ "data": self.data,
70
+ "files": self.files,
71
+ "json": self.json,
72
+ }
73
+
74
+
75
+ def unwrap_hint(hint: type | None) -> tuple[type | None, bool, list[object]]:
76
+ """Unwraps nested `Optional` and `Annotated` layers in any order.
77
+
78
+ `get_type_hints` re-wraps `= None` parameters in `Optional[...]`, so the layers can nest
79
+ in either order and repeat.
80
+
81
+ Returns:
82
+ Tuple of the innermost type, whether `None` was found, and all markers.
83
+ """
84
+ is_optional = False
85
+ markers: list[object] = []
86
+ while True:
87
+ if get_origin(hint) is Annotated:
88
+ args = get_args(hint)
89
+ hint = args[0]
90
+ markers.extend(args[1:])
91
+ continue
92
+ unwrapped, hint_is_optional = unwrap_optional(hint)
93
+ is_optional = is_optional or hint_is_optional
94
+ if unwrapped is hint:
95
+ return hint, is_optional, markers
96
+ hint = unwrapped
97
+
98
+
99
+ def classify(
100
+ name: str, hint: type | None, path_parts: Collection[str], param: Parameter
101
+ ) -> tuple[str, Marker | type[Body]]:
102
+ """Resolves the binding of a parameter: an explicit marker or an implicit path parameter."""
103
+ resolved_hint, is_optional, markers = unwrap_hint(hint)
104
+ for extra in markers: # pragma: no branch
105
+ marker = extra() if extra in (Path, Query, Field, Part, Header, Cookie) else extra
106
+ if isinstance(marker, Path):
107
+ marker.validate(name, resolved_hint, is_optional, param)
108
+ if isinstance(marker, (Query, Field, Header, Cookie, Part)):
109
+ marker.validate(name, resolved_hint)
110
+ if isinstance(marker, Marker):
111
+ return (marker.name or name, marker)
112
+ if extra is Body:
113
+ Body.validate(name, resolved_hint)
114
+ return (name, Body)
115
+ raise TypeError(f"Unexpected extra: {extra}")
116
+ if name in path_parts:
117
+ Path.validate(name, resolved_hint, is_optional, param)
118
+ return (name, Path())
119
+ raise TypeError(f"Parameter {name!r} has no Query/Field/Part/Body binding or path match")
120
+
121
+
122
+ def rejects_union(return_type: type) -> bool:
123
+ """A bare union, or `Response[...]` wrapping one directly, is not decodable."""
124
+ if get_origin(return_type) in (Union, UnionType):
125
+ return True
126
+ if get_origin(return_type) is Response:
127
+ (inner_type,) = get_args(return_type)
128
+ return get_origin(inner_type) in (Union, UnionType)
129
+ return False
130
+
131
+
132
+ def build_plan( # noqa: C901
133
+ func: Callable[Concatenate[AnyC_T, P], Parsed_T]
134
+ | Callable[Concatenate[AnyC_T, P], Coroutine[Any, Any, Parsed_T]],
135
+ path: str,
136
+ ) -> tuple[list[ParamPlan], type[Parsed_T]]:
137
+ """Builds a parameter plan and return type for a callable."""
138
+ hints: dict[str, type] = get_type_hints(func, include_extras=True)
139
+ return_type = hints.get("return", MISSING)
140
+ if return_type is MISSING:
141
+ raise ValueError("No return type annotated")
142
+ if rejects_union(return_type):
143
+ raise TypeError(f"Return type must not be a union: {return_type!r}")
144
+
145
+ path_parts = {name for _, name, _, _ in string.Formatter().parse(path) if name}
146
+
147
+ sig = inspect.signature(func)
148
+ plan: list[ParamPlan] = []
149
+ kind: Param_T
150
+ for py_name, param in sig.parameters.items():
151
+ if py_name == "self":
152
+ continue
153
+ wire_name, marker = classify(py_name, hints.get(py_name), path_parts, param)
154
+ if marker is Body:
155
+ kind = "body"
156
+ elif isinstance(marker, Path):
157
+ kind = "path"
158
+ elif isinstance(marker, Query):
159
+ kind = "query"
160
+ elif isinstance(marker, Field):
161
+ kind = "field"
162
+ elif isinstance(marker, Header):
163
+ kind = "header"
164
+ elif isinstance(marker, Cookie):
165
+ kind = "cookie"
166
+ else:
167
+ kind = "part"
168
+ cookie_override = marker.override if isinstance(marker, Cookie) else False
169
+ plan.append(
170
+ ParamPlan(
171
+ py_name=py_name, wire_name=wire_name, kind=kind, cookie_override=cookie_override
172
+ )
173
+ )
174
+ return plan, return_type
175
+
176
+
177
+ def build_request(
178
+ method: str,
179
+ path: str,
180
+ plan: list[ParamPlan],
181
+ values: Mapping[str, object],
182
+ jar: Mapping[str, str] | None = None,
183
+ ) -> RequestSpec:
184
+ """Builds a request spec from the parameter plan and provided values.
185
+
186
+ Args:
187
+ method: HTTP method for the request.
188
+ path: URL path template with placeholders.
189
+ plan: Parameter plan describing how to map values to the request.
190
+ values: Mapping of parameter names to their values.
191
+ jar: Snapshot of the current cookie jar.
192
+ """
193
+ path_args: dict[str, object] = {}
194
+ params: dict[str, QueryValue] = {}
195
+ headers: dict[str, str] = {}
196
+ cookies: dict[str, str] = {}
197
+ fields: dict[str, object] = {}
198
+ files: dict[str, PartValue] = {}
199
+ body: JsonValue = None
200
+ for p in plan:
201
+ value = values.get(p.py_name)
202
+ if p.kind == "path":
203
+ path_args[p.wire_name] = value
204
+ elif value is None:
205
+ continue # None values are omitted for queries, fields, headers, and cookies
206
+ elif p.kind == "query":
207
+ params[p.wire_name] = value # type: ignore[assignment]
208
+ elif p.kind == "field":
209
+ fields[p.wire_name] = value
210
+ elif p.kind == "part":
211
+ files[p.wire_name] = value # type: ignore[assignment]
212
+ elif p.kind == "header":
213
+ headers[p.wire_name] = str(value)
214
+ elif p.kind == "cookie":
215
+ jar_value = None if p.cookie_override else (jar or {}).get(p.wire_name)
216
+ cookies[p.wire_name] = jar_value if jar_value is not None else str(value)
217
+ else:
218
+ body = msgspec.to_builtins(value)
219
+ if cookies:
220
+ headers["cookie"] = "; ".join(
221
+ f"{k}={urllib.parse.quote(v, safe='')}" for k, v in cookies.items()
222
+ )
223
+ return RequestSpec(
224
+ method=method,
225
+ url=path.format(**{k: urllib.parse.quote(str(v), safe="") for k, v in path_args.items()}),
226
+ params=params,
227
+ headers=headers or None,
228
+ data=fields or None,
229
+ files=files or None,
230
+ json=body,
231
+ )
mxhttp/response.py ADDED
@@ -0,0 +1,174 @@
1
+ """Processing steps for the raw `httpx.Reponse`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Generic, get_args, get_origin, overload
6
+
7
+ import msgspec
8
+
9
+ from mxhttp.sse import Event, SseBuilder
10
+ from mxhttp.types import AnyC_T, Parsed_T, ResponseHandler, is_parsed_type
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import AsyncIterator, Callable, Iterator
14
+ from types import ModuleType
15
+
16
+ import httpx
17
+
18
+ from mxhttp.consumer import AsyncConsumer, BaseConsumer, SyncConsumer
19
+ from mxhttp.request import RequestSpec
20
+
21
+
22
+ class Response(msgspec.Struct, Generic[Parsed_T]):
23
+ """Wraps decoded `data` alongside the raw `httpx.Response`."""
24
+
25
+ data: Parsed_T
26
+ response: httpx.Response
27
+
28
+
29
+ @overload
30
+ def decode(
31
+ response: httpx.Response, return_type: type[Response[Parsed_T]]
32
+ ) -> Response[Parsed_T]: ...
33
+ @overload
34
+ def decode(response: httpx.Response, return_type: type[None]) -> None: ...
35
+ @overload
36
+ def decode(response: httpx.Response, return_type: type[Parsed_T]) -> Parsed_T: ...
37
+ def decode( # noqa: PLR0911
38
+ response: httpx.Response, return_type: type[Parsed_T | None]
39
+ ) -> Parsed_T | None:
40
+ """Decodes a HTTP response into the specified return type.
41
+
42
+ `httpx.Response` passes through, `str` and `bytes` use `.text` and `.content` directly,
43
+ `None` discards the body without decoding it, and `pydantic.BaseModel` subclasses
44
+ validate via `model_validate_json`. All other types decode via `msgspec.json.decode`.
45
+ """
46
+ import httpx
47
+
48
+ if not is_parsed_type(return_type):
49
+ return None
50
+
51
+ if get_origin(return_type) is Response:
52
+ (inner_type,) = get_args(return_type)
53
+ return Response(decode(response, inner_type), response) # type: ignore[return-value]
54
+ if isinstance(return_type, type): # pragma: no branch
55
+ if return_type is httpx.Response:
56
+ return response # type: ignore[return-value]
57
+ if issubclass(return_type, str):
58
+ return response.text # type: ignore[return-value]
59
+ if issubclass(return_type, bytes):
60
+ return response.content # type: ignore[return-value]
61
+ pydantic: ModuleType | None
62
+ try:
63
+ import pydantic
64
+ except ImportError: # pragma: no cover
65
+ pydantic = None
66
+ if pydantic is not None and issubclass(return_type, pydantic.BaseModel):
67
+ return return_type.model_validate_json( # type: ignore[attr-defined, no-any-return]
68
+ response.content
69
+ )
70
+ return msgspec.json.decode(response.content, type=return_type)
71
+
72
+
73
+ def apply_response_handler(self: BaseConsumer, response: httpx.Response) -> httpx.Response:
74
+ """Applies the response hook, defaults to `raise_for_status` if none is set."""
75
+ if self._response_handler:
76
+ return self._response_handler(response)
77
+ return response.raise_for_status()
78
+
79
+
80
+ def apply_streaming_response_handler(
81
+ self: BaseConsumer, response: httpx.Response
82
+ ) -> httpx.Response:
83
+ """Applies the streaming response hook, defaults to `raise_for_status` if unset.
84
+
85
+ Only the status line and headers are available at this point.
86
+ """
87
+ if self._streaming_response_handler:
88
+ return self._streaming_response_handler(response)
89
+ return response.raise_for_status()
90
+
91
+
92
+ def response_handler(
93
+ hook: ResponseHandler,
94
+ ) -> Callable[[type[AnyC_T]], type[AnyC_T]]:
95
+ """Class decorator that runs every raw response through `hook` before decoding.
96
+
97
+ Overrides the default hook, which calls `response.raise_for_status()`.
98
+ Does not apply to streaming endpoints. Use `@streaming_response_handler` for those.
99
+ """
100
+
101
+ def decorate(cls: type[AnyC_T]) -> type[AnyC_T]:
102
+ cls._response_handler = staticmethod(hook)
103
+ return cls
104
+
105
+ return decorate
106
+
107
+
108
+ def streaming_response_handler(
109
+ hook: ResponseHandler,
110
+ ) -> Callable[[type[AnyC_T]], type[AnyC_T]]:
111
+ """Class decorator that runs every streaming response through `hook` before yielding.
112
+
113
+ Applies only to streaming endpoints. Use `@response_handler` for other endpoints.
114
+ Only the status line and headers can be inspected.
115
+ """
116
+
117
+ def decorate(cls: type[AnyC_T]) -> type[AnyC_T]:
118
+ cls._streaming_response_handler = staticmethod(hook)
119
+ return cls
120
+
121
+ return decorate
122
+
123
+
124
+ def stream_sync(self: SyncConsumer, spec: RequestSpec) -> Iterator[bytes]:
125
+ """Streams the response body in chunks instead of buffering it.
126
+
127
+ Runs the `@streaming_response_handler` hook before yielding events.
128
+ Any incomplete event when the stream ends is discarded.
129
+ """
130
+ with self.session.stream(spec.method, spec.url, **spec.to_kwargs()) as response:
131
+ apply_streaming_response_handler(self, response)
132
+ yield from response.iter_bytes()
133
+
134
+
135
+ async def stream_async(self: AsyncConsumer, spec: RequestSpec) -> AsyncIterator[bytes]:
136
+ """Streams the response body in chunks instead of buffering it.
137
+
138
+ Runs the `@streaming_response_handler` hook before yielding events.
139
+ Any incomplete event when the stream ends is discarded.
140
+ """
141
+ async with self.session.stream(spec.method, spec.url, **spec.to_kwargs()) as response:
142
+ apply_streaming_response_handler(self, response)
143
+ async for chunk in response.aiter_bytes():
144
+ yield chunk
145
+
146
+
147
+ def sse_sync(self: SyncConsumer, spec: RequestSpec) -> Iterator[Event]:
148
+ """Streams the response as parsed Server-Sent Events.
149
+
150
+ Runs the `@streaming_response_handler` hook before yielding events.
151
+ Any incomplete event when the stream ends is discarded.
152
+ """
153
+ builder = SseBuilder()
154
+ with self.session.stream(spec.method, spec.url, **spec.to_kwargs()) as response:
155
+ apply_streaming_response_handler(self, response)
156
+ for line in response.iter_lines():
157
+ event = builder.feed(line)
158
+ if event is not None:
159
+ yield event
160
+
161
+
162
+ async def sse_async(self: AsyncConsumer, spec: RequestSpec) -> AsyncIterator[Event]:
163
+ """Streams the response as parsed Server-Sent Events.
164
+
165
+ Runs the `@streaming_response_handler` hook before yielding events.
166
+ Any incomplete event when the stream ends is discarded.
167
+ """
168
+ builder = SseBuilder()
169
+ async with self.session.stream(spec.method, spec.url, **spec.to_kwargs()) as response:
170
+ apply_streaming_response_handler(self, response)
171
+ async for line in response.aiter_lines():
172
+ event = builder.feed(line)
173
+ if event is not None:
174
+ yield event
mxhttp/sse.py ADDED
@@ -0,0 +1,60 @@
1
+ """Handles Server-Sent Event streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import get_args
6
+
7
+ import msgspec
8
+
9
+ from mxhttp.types import SseField_T
10
+
11
+ SSE_FIELDS: frozenset[SseField_T] = frozenset(get_args(SseField_T))
12
+
13
+
14
+ class Event(msgspec.Struct):
15
+ """Stores a parsed Server-Sent Event."""
16
+
17
+ data: str = ""
18
+ """The raw payload."""
19
+ event: str = "message"
20
+ id: str | None = None
21
+ retry: int | None = None
22
+
23
+
24
+ class SseBuilder(msgspec.Struct):
25
+ """Builds SSE field lines into `Event` objects, one per blank-line-terminated block."""
26
+
27
+ data_lines: list[str] = []
28
+ event: str = "message"
29
+ last_id: str | None = None
30
+ retry: int | None = None
31
+
32
+ def feed(self, line: str) -> Event | None:
33
+ """Feeds one decoded line and returns a completed `Event` on a blank line, else `None`."""
34
+ if not line:
35
+ if not self.data_lines:
36
+ return None # a blank line with nothing accumulated dispatches nothing
37
+ built = Event(
38
+ data="\n".join(self.data_lines),
39
+ event=self.event,
40
+ id=self.last_id,
41
+ retry=self.retry,
42
+ )
43
+ self.data_lines = []
44
+ self.event = "message"
45
+ return built
46
+ if line.startswith(":"):
47
+ return None # comment line
48
+ field, _, value = line.partition(":")
49
+ if field not in SSE_FIELDS:
50
+ return None # unrecognized field, per spec
51
+ value = value.removeprefix(" ")
52
+ if field == "data":
53
+ self.data_lines.append(value)
54
+ elif field == "event":
55
+ self.event = value
56
+ elif field == "id":
57
+ self.last_id = value
58
+ elif value.isdigit(): # field == "retry"
59
+ self.retry = int(value)
60
+ return None
mxhttp/types.py ADDED
@@ -0,0 +1,79 @@
1
+ """Defines models and types for the HTTP client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from enum import Enum, auto
7
+ from typing import IO, TYPE_CHECKING, Literal, TypeAlias, TypeVar
8
+
9
+ from typing_extensions import TypeIs
10
+
11
+ if TYPE_CHECKING:
12
+ import httpx
13
+ import msgspec
14
+ from _typeshed import DataclassInstance
15
+ from attrs import AttrsInstance
16
+
17
+ from mxhttp.consumer import AsyncConsumer, BaseConsumer, SyncConsumer # cyclic
18
+
19
+
20
+ class Missing(Enum):
21
+ """Sentinel value representing a missing or absent field."""
22
+
23
+ SENTINEL = auto()
24
+
25
+
26
+ MISSING = Missing.SENTINEL
27
+ """Sentinel value representing a missing or absent field."""
28
+
29
+ AnyC_T = TypeVar("AnyC_T", bound="BaseConsumer")
30
+ SyncC_T = TypeVar("SyncC_T", bound="SyncConsumer")
31
+ AsyncC_T = TypeVar("AsyncC_T", bound="AsyncConsumer")
32
+
33
+ JsonValue: TypeAlias = bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] | None
34
+ TypedDictLike: TypeAlias = "Mapping[str, object]"
35
+ DataclassLike: TypeAlias = "msgspec.Struct | DataclassInstance | AttrsInstance | TypedDictLike"
36
+ JsonDecodable: TypeAlias = "JsonValue | DataclassLike"
37
+ Parsed_T = TypeVar(
38
+ "Parsed_T",
39
+ bound=(
40
+ "httpx.Response | str | bytes | DataclassLike | Sequence[JsonDecodable] | Mapping[str, JsonDecodable]" # noqa: E501
41
+ ),
42
+ )
43
+ """Raw response, raw content, dataclass-like structures, or JSON-decodable types."""
44
+
45
+ Param_T: TypeAlias = Literal["path", "body", "query", "field", "part", "header", "cookie"]
46
+ """HTTP request parameters."""
47
+
48
+ Method_T: TypeAlias = Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]
49
+ """HTTP methods."""
50
+
51
+ SseField_T: TypeAlias = Literal["data", "event", "id", "retry"]
52
+ """Accepted SSE line prefixes, others are ignored."""
53
+
54
+ ValidPath_T: TypeAlias = str | int | float
55
+ """Allowed types for a path argument."""
56
+
57
+ FileContent: TypeAlias = bytes | str | IO[bytes]
58
+ """The content of a multipart file part: in-memory bytes/text, or a file-like object."""
59
+
60
+ PartValue: TypeAlias = (
61
+ FileContent
62
+ | tuple[str | None, FileContent]
63
+ | tuple[str | None, FileContent, str | None]
64
+ | tuple[str | None, FileContent, str | None, Mapping[str, str]]
65
+ )
66
+ """A multipart file part: bare content, or a (filename, content, content-type, headers) tuple."""
67
+
68
+ ParamValue = str | int | float | bool
69
+ """A scalar query, field, header, or cookie value accepted by `httpx`."""
70
+
71
+ QueryValue: TypeAlias = "ParamValue | Sequence[ParamValue]"
72
+ """A `ParamValue` scalar or a sequence of those, repeated as `key=a&key=b&...`."""
73
+
74
+ ResponseHandler: TypeAlias = "Callable[[httpx.Response], httpx.Response]"
75
+
76
+
77
+ def is_parsed_type(return_type: type[Parsed_T | None]) -> TypeIs[type[Parsed_T]]:
78
+ """Check whether the `return_type` is not `NoneType`."""
79
+ return return_type is not type(None)
@@ -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.
@@ -0,0 +1,14 @@
1
+ mxhttp-1.0.0.dist-info/METADATA,sha256=ITPwUHMUbVVwitV_vwNZ18s4h9H_oGIdz7U6smYPW-4,8402
2
+ mxhttp-1.0.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90
3
+ mxhttp-1.0.0.dist-info/entry_points.txt,sha256=6OYgBcLyFCUgeqLgnvMyOJxPCWzgy7se4rLPKtNonMs,34
4
+ mxhttp-1.0.0.dist-info/licenses/LICENSE,sha256=OorQaF5r-zKnjJfsc350yUOKGYNbJCU1iMrycfu1TiM,1069
5
+ mxhttp/__init__.py,sha256=D4mUBD3oSt2LUdFe-qOUjWZx_0PsACnw1KOJUPW9MY8,795
6
+ mxhttp/consumer.py,sha256=Lvwr-HKrqtgciCSdjMYvlcQ7780ZOB3FZRBY8ggHh3c,2709
7
+ mxhttp/endpoint.py,sha256=oGD4ZXxwW48obIkbevyjcTB7FMcXzvVE7JLjsz1MYgA,7102
8
+ mxhttp/markers.py,sha256=az_w0Hakj8p2PONlaXYF4zCgXyBXMrcQmnlTsbnBAv8,7622
9
+ mxhttp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ mxhttp/request.py,sha256=ZMVPUgEGyYRC39l7UiSnB8elVWtDD1a7eNyiAx09biQ,7895
11
+ mxhttp/response.py,sha256=EvepcSen-on800NGO8H9cTnTvUun2OHd_dZvyO05x3U,6445
12
+ mxhttp/sse.py,sha256=K8PP6e7-UK1IJlDqOZ-eKZiIUSyU1_QhQx7RPogeMfs,1806
13
+ mxhttp/types.py,sha256=KIm84Ken2MDUGhEILgpxWaDA-Jm0oclV2vk_XWYHDXY,2802
14
+ mxhttp-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: pdm-backend (2.4.9)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+
3
+ [gui_scripts]
4
+
@@ -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.