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 +36 -0
- mxhttp/consumer.py +95 -0
- mxhttp/endpoint.py +220 -0
- mxhttp/markers.py +200 -0
- mxhttp/py.typed +0 -0
- mxhttp/request.py +231 -0
- mxhttp/response.py +174 -0
- mxhttp/sse.py +60 -0
- mxhttp/types.py +79 -0
- mxhttp-1.0.0.dist-info/METADATA +202 -0
- mxhttp-1.0.0.dist-info/RECORD +14 -0
- mxhttp-1.0.0.dist-info/WHEEL +4 -0
- mxhttp-1.0.0.dist-info/entry_points.txt +4 -0
- mxhttp-1.0.0.dist-info/licenses/LICENSE +21 -0
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,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.
|