xtr-http-client-contracts 4.0.2__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.
Files changed (27) hide show
  1. xtr_http_client_contracts/__init__.py +47 -0
  2. xtr_http_client_contracts/exception/__init__.py +40 -0
  3. xtr_http_client_contracts/exception/client_error.py +16 -0
  4. xtr_http_client_contracts/exception/decoding_error.py +32 -0
  5. xtr_http_client_contracts/exception/http_client_error.py +21 -0
  6. xtr_http_client_contracts/exception/http_error.py +51 -0
  7. xtr_http_client_contracts/exception/invalid_argument_error.py +27 -0
  8. xtr_http_client_contracts/exception/json_error.py +19 -0
  9. xtr_http_client_contracts/exception/redirection_error.py +21 -0
  10. xtr_http_client_contracts/exception/server_error.py +15 -0
  11. xtr_http_client_contracts/exception/transport_error.py +31 -0
  12. xtr_http_client_contracts/exception/transport_timeout_error.py +27 -0
  13. xtr_http_client_contracts/http_client_interface.py +124 -0
  14. xtr_http_client_contracts/http_response_interface.py +148 -0
  15. xtr_http_client_contracts/options_defaults.py +69 -0
  16. xtr_http_client_contracts/py.typed +0 -0
  17. xtr_http_client_contracts/request_options.py +106 -0
  18. xtr_http_client_contracts/testing/__init__.py +12 -0
  19. xtr_http_client_contracts/testing/_fixture_request.py +184 -0
  20. xtr_http_client_contracts/testing/_fixture_response.py +80 -0
  21. xtr_http_client_contracts/testing/_fixture_routes.py +321 -0
  22. xtr_http_client_contracts/testing/http_client_test_case.py +1035 -0
  23. xtr_http_client_contracts/testing/test_http_server.py +213 -0
  24. xtr_http_client_contracts-4.0.2.dist-info/METADATA +271 -0
  25. xtr_http_client_contracts-4.0.2.dist-info/RECORD +27 -0
  26. xtr_http_client_contracts-4.0.2.dist-info/WHEEL +4 -0
  27. xtr_http_client_contracts-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,213 @@
1
+ """A disposable HTTP server a client implementation can be tested against."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from typing import TYPE_CHECKING, ClassVar, Final, cast, final
8
+
9
+ from ._fixture_request import read_request
10
+ from ._fixture_routes import respond
11
+
12
+ if TYPE_CHECKING:
13
+ from types import TracebackType
14
+ from typing import Self
15
+
16
+ __all__ = ["TestHttpServer"]
17
+
18
+ _HOST: Final = "127.0.0.1"
19
+
20
+ #: Ask the operating system for a free port, so two suites running at once never collide.
21
+ _ANY_FREE_PORT: Final = 0
22
+
23
+
24
+ @final
25
+ class TestHttpServer:
26
+ """The server the conformance suite points a client at.
27
+
28
+ It runs on an event loop of its own, in a background thread, because the client under test
29
+ owns the loop its own code runs on: a client that stops pumping its loop — which is exactly
30
+ what a timeout test provokes — must not stop the server that is meant to keep answering it.
31
+
32
+ ``start()`` returns once the port is listening, and both ``start()`` and ``stop()`` may be
33
+ called again at any point, so one instance can live for a whole test session::
34
+
35
+ @pytest.fixture(scope="session")
36
+ def http_server() -> Iterator[TestHttpServer]:
37
+ with TestHttpServer() as server:
38
+ yield server
39
+
40
+ Unless a port is named, the server listens on whichever free port the operating system
41
+ hands out, which :attr:`port` and :attr:`base_uri` report once :meth:`start` has returned.
42
+ A restarted server may be given a different one.
43
+
44
+ What each route answers is described in ``_fixture_routes``; what the echoing ones report
45
+ about the request is described in ``_fixture_request``.
46
+ """
47
+
48
+ __test__: ClassVar[bool] = False # a library class, not a pytest test case
49
+
50
+ __slots__ = (
51
+ "_bound_port",
52
+ "_failure",
53
+ "_host",
54
+ "_loop",
55
+ "_port",
56
+ "_ready",
57
+ "_stop",
58
+ "_thread",
59
+ )
60
+
61
+ def __init__(self, host: str = _HOST, port: int = _ANY_FREE_PORT) -> None:
62
+ """Bind nothing yet: the socket is opened by :meth:`start`."""
63
+ self._host = host
64
+ self._port = port
65
+ self._bound_port: int | None = None
66
+ self._ready = threading.Event()
67
+ self._thread: threading.Thread | None = None
68
+ self._loop: asyncio.AbstractEventLoop | None = None
69
+ self._stop: asyncio.Event | None = None
70
+ self._failure: BaseException | None = None
71
+
72
+ @property
73
+ def host(self) -> str:
74
+ """The address the server listens on."""
75
+ return self._host
76
+
77
+ @property
78
+ def port(self) -> int:
79
+ """The port the server listens on.
80
+
81
+ Raises:
82
+ RuntimeError: When the server was asked for any free port and has not been
83
+ started, so no port has been chosen yet.
84
+ """
85
+ if self._bound_port is not None:
86
+ return self._bound_port
87
+ if self._port == _ANY_FREE_PORT:
88
+ raise RuntimeError(
89
+ "The server listens on a free port chosen by start(); start it first."
90
+ )
91
+ return self._port
92
+
93
+ @property
94
+ def base_uri(self) -> str:
95
+ """The origin a client should send its requests to."""
96
+ return f"http://{self._host}:{self.port}"
97
+
98
+ def start(self) -> None:
99
+ """Listen, and return once the port accepts connections.
100
+
101
+ Starting an already-running server does nothing, so a fixture may start it defensively.
102
+ Whatever stopped the thread from listening — a port already taken, most often — is
103
+ raised here rather than left to make every later request fail obscurely.
104
+ """
105
+ if self._thread is not None:
106
+ return
107
+ self._failure = None
108
+ self._ready.clear()
109
+ thread = threading.Thread(target=self._run, name="xtr-fixture-http-server", daemon=True)
110
+ self._thread = thread
111
+ thread.start()
112
+ _ = self._ready.wait()
113
+ failure = self._failure
114
+ if failure is not None:
115
+ self._thread = None
116
+ self._failure = None
117
+ raise failure
118
+
119
+ def stop(self) -> None:
120
+ """Close the port and everything still connected to it.
121
+
122
+ Stopping a server that is not running does nothing, and a stopped server can be started
123
+ again; a route still writing when this is called is cut off rather than waited for.
124
+ """
125
+ thread, loop, stop = self._thread, self._loop, self._stop
126
+ self._thread = None
127
+ self._loop = None
128
+ self._stop = None
129
+ if thread is None:
130
+ return
131
+ if loop is not None and stop is not None:
132
+ _ = loop.call_soon_threadsafe(stop.set)
133
+ thread.join()
134
+
135
+ def __enter__(self) -> Self:
136
+ """Start, for a test that wants the server for the length of a block."""
137
+ self.start()
138
+ return self
139
+
140
+ def __exit__(
141
+ self,
142
+ exc_type: type[BaseException] | None,
143
+ exc: BaseException | None,
144
+ traceback: TracebackType | None,
145
+ ) -> None:
146
+ """Stop, whatever the block did."""
147
+ del exc, exc_type, traceback
148
+ self.stop()
149
+
150
+ def _run(self) -> None:
151
+ """The background thread: one event loop, used for nothing else."""
152
+ try:
153
+ asyncio.run(self._serve())
154
+ except BaseException as failure: # noqa: BLE001 - re-raised by start()
155
+ self._failure = failure
156
+ finally:
157
+ self._ready.set()
158
+
159
+ async def _serve(self) -> None:
160
+ self._loop = asyncio.get_running_loop()
161
+ stop = asyncio.Event()
162
+ self._stop = stop
163
+ server = await asyncio.start_server(self._handle, self._host, self._port)
164
+ bound = cast("tuple[object, ...]", server.sockets[0].getsockname())
165
+ self._bound_port = cast("int", bound[1])
166
+ self._ready.set()
167
+ try:
168
+ _ = await stop.wait()
169
+ finally:
170
+ # Order matters: everything connected goes before the listener does. A connection
171
+ # that attaches after a server has been closed makes the standard library wake
172
+ # waiters it already dropped, and the socket cleanup that was underneath dies with
173
+ # it — leaving the socket open. Nothing can attach once no accept is in flight.
174
+ await _end_everything_still_serving()
175
+ server.close()
176
+ await server.wait_closed()
177
+
178
+ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
179
+ """Answer one request and close, as every response promises in its headers."""
180
+ try:
181
+ request = await read_request(reader, writer, self._host)
182
+ if request is not None:
183
+ await respond(request, writer)
184
+ writer.close()
185
+ await writer.wait_closed()
186
+ except (OSError, EOFError):
187
+ pass # the client hung up mid-exchange, which several routes exist to provoke
188
+ finally:
189
+ # However the exchange ended — cut short by a cancelled route included — the socket
190
+ # may not outlive the handler, or the loop is closed with it still open. Aborting
191
+ # an already-closed transport does nothing, so the orderly path above still wins.
192
+ writer.transport.abort()
193
+
194
+
195
+ async def _end_everything_still_serving() -> None:
196
+ """Cancel every other task on this loop, wait for it, and let its socket close.
197
+
198
+ This is what ``asyncio.run`` would do on the way out, done here instead while the loop can
199
+ still run, because the runner drops what it cancels with its sockets still open.
200
+
201
+ The two turns of the loop around the cancelling are both load-bearing. Before: a connection
202
+ accepted in the same breath as the shutdown has a task that has not run at all yet, holding
203
+ the accepted socket and nothing that could close it; one turn lets it reach the transport,
204
+ which cancelling then closes. After: losing a connection is a callback, and the sockets the
205
+ cancelled tasks just abandoned are still waiting for it.
206
+ """
207
+ await asyncio.sleep(0)
208
+ current = asyncio.current_task()
209
+ serving = [task for task in asyncio.all_tasks() if task is not current]
210
+ for task in serving:
211
+ _ = task.cancel()
212
+ _ = await asyncio.gather(*serving, return_exceptions=True)
213
+ await asyncio.sleep(0)
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-http-client-contracts
3
+ Version: 4.0.2
4
+ Summary: The async HTTP client contract alone: a request call, a response read on demand, typed options and the errors they raise.
5
+ Keywords: http,http-client,contracts,interface,protocol,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Internet :: WWW/HTTP
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: xtr-http-message-contracts>=4.0,<5
21
+ Requires-Dist: anyio>=4.0 ; extra == 'testing'
22
+ Requires-Dist: pytest>=8 ; extra == 'testing'
23
+ Requires-Dist: xtr-http-message>=4.0,<5 ; extra == 'testing'
24
+ Requires-Python: >=3.11
25
+ Provides-Extra: testing
26
+ Description-Content-Type: text/markdown
27
+
28
+ <div align="center">
29
+
30
+ # xtr-http-client-contracts
31
+
32
+ **The async HTTP client contract, and nothing else — so a library that calls out installs no backend.**
33
+
34
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
35
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
36
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
37
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
38
+
39
+ </div>
40
+
41
+ ---
42
+
43
+ ## Why?
44
+
45
+ A library that sends an HTTP request should not choose what sends it. It takes the client
46
+ interface, asks for a method and a URL, and leaves connection pooling, redirects and TLS to
47
+ whatever the application wired behind it.
48
+
49
+ That needs the *seam*, not a client. This package is that seam: the client interface, the
50
+ response interface, the typed request options, the errors they raise, and a conformance suite
51
+ any implementation runs against itself.
52
+
53
+ ## Install
54
+
55
+ ```sh
56
+ uv add xtr-http-client-contracts
57
+ uv add --dev "xtr-http-client-contracts[testing]" # + what the conformance suite runs on
58
+ ```
59
+
60
+ Requires Python 3.11+. The only runtime dependency beyond `typing-extensions` is
61
+ [xtr-http-message-contracts](../xtr-http-message-contracts): a client response is an HTTP
62
+ response before it is anything else.
63
+
64
+ ## What a caller depends on
65
+
66
+ ```python
67
+ from xtr_http_client_contracts import HttpClientInterface
68
+
69
+
70
+ async def paid_orders(client: HttpClientInterface) -> dict[str, object]:
71
+ response = await client.request("GET", "https://api.example.com/orders", query={"paid": "1"})
72
+ async with response:
73
+ return await response.to_array()
74
+ ```
75
+
76
+ `HttpClientInterface` is a `@runtime_checkable` `Protocol` with three members:
77
+
78
+ | Member | Answers |
79
+ |---|---|
80
+ | `await request(method, url, **options)` | an `HttpResponseInterface`, once the status line and the headers are in |
81
+ | `await send_request(request, **options)` | the same, for a [message request](../xtr-http-message-contracts) that already knows its own method, URI, headers and body |
82
+ | `with_options(**options)` | another client of the same type, whose defaults are these options over this one's |
83
+
84
+ Neither call raises because of the status: a 404 or a 503 is an answer and comes back as a
85
+ response. What they do raise is `TransportError` when nothing answered,
86
+ `TransportTimeoutError` when nothing answered in time, and `InvalidArgumentError` for a
87
+ method, a URL or an option that is refused before anything is sent.
88
+
89
+ `send_request` says what wins when a request and the options both have an opinion: the request
90
+ is authoritative. A relative URI is resolved against `base_uri`, `query` is merged into the
91
+ URI, a header from `headers` is sent only under a name the request does not carry,
92
+ `auth_basic` and `auth_bearer` apply only without an `Authorization` of the request's own, and
93
+ `body` or `json` alongside a request that already carries a body is refused, word for word:
94
+
95
+ ```
96
+ Define either the request body or the "body" or "json" option, setting both is not supported.
97
+ ```
98
+
99
+ Several requests run at once by being made from tasks of one task group; nothing in the
100
+ contract is per client state a caller has to serialize.
101
+
102
+ ## The response
103
+
104
+ `HttpResponseInterface` extends the message `ResponseInterface`, so the status code, the reason
105
+ phrase, the protocol version, the headers, `get_body()` and the `with_*` copies are the message
106
+ contract's — code typed against the plain response interface takes one of these as it is, and
107
+ `isinstance` is what reveals the extras:
108
+
109
+ ```python
110
+ from xtr_http_client_contracts import HttpResponseInterface
111
+ from xtr_http_message_contracts import ResponseInterface
112
+
113
+
114
+ def log(response: ResponseInterface) -> None:
115
+ print(response.get_status_code())
116
+ if isinstance(response, HttpResponseInterface):
117
+ print(response.get_info("total_time"))
118
+ ```
119
+
120
+ What a client adds on top of a message response:
121
+
122
+ | Member | Is |
123
+ |---|---|
124
+ | `await get_content(throw=True)` | every byte of the body, waiting for the rest of it to arrive |
125
+ | `await to_array(throw=True)` | that body decoded as a JSON object |
126
+ | `get_info(key=None)` | what the transfer knows about itself, never waiting and never raising |
127
+ | `await cancel()` | the transfer abandoned, and what it held let go of |
128
+ | `async with response:` | a block that owns the transfer and closes the body on the way out |
129
+
130
+ `throw` is the one switch: left on, an unsuccessful status is raised by reading the body —
131
+ `RedirectionError`, `ClientError`, `ServerError` — rather than returned. `throw=False` hands
132
+ the body over whatever the status says, which is how a 4xx carrying an explanation is read.
133
+
134
+ `get_info()` always answers these entries: `canceled`, `error`, `http_code` (`0` before a
135
+ status arrived), `http_method`, `redirect_count`, `redirect_url`, `response_headers` (the raw
136
+ status and header lines of every hop), `start_time`, `total_time`, `url` and `user_data`.
137
+ `primary_ip` and `retry_count` are there when they are known. Because it neither waits nor
138
+ raises, it is readable while the transfer is still going on.
139
+
140
+ ## Request options
141
+
142
+ `RequestOptions` is a `total=False` `TypedDict`, so every option is checked where it is
143
+ written and an option left out is one nobody asked about — the client fills it in from its own
144
+ defaults, which start at `OPTIONS_DEFAULTS`.
145
+
146
+ ```python
147
+ from xtr_http_client_contracts import OPTIONS_DEFAULTS, RequestOptions
148
+
149
+ defaults: RequestOptions = {**OPTIONS_DEFAULTS, "timeout": 5, "max_redirects": 0}
150
+ ```
151
+
152
+ | Option | Says |
153
+ |---|---|
154
+ | `auth_basic`, `auth_bearer` | the credentials to send; both at once is refused |
155
+ | `query` | parameters merged into the URL, winning over one of the same name already there |
156
+ | `headers` | the headers to send, one value or several per name |
157
+ | `body`, `json` | what to send — bytes, text, a mapping as a form, an async iterable produced as it goes; or a value encoded as JSON. Both at once is refused |
158
+ | `user_data` | anything to find again under the `user_data` entry of `get_info()`; never read |
159
+ | `max_redirects` | how many hops to follow; zero or less answers with the 3xx |
160
+ | `http_version` | `"1.0"`, `"1.1"`, `"2"` or `"2.0"`; `None` leaves it to the connection |
161
+ | `base_uri` | what a relative URL resolves against. A sequence of them is for a client that retries, which moves along it |
162
+ | `timeout` | how long the transfer may go quiet for. `None` is 60 seconds, not "no limit" |
163
+ | `max_duration`, `max_connect_duration` | limits on the whole request and on connecting. Zero *is* "no limit" |
164
+ | `resolve` | host to address, instead of asking a resolver — what pins a request to an address already checked |
165
+ | `proxy` | the proxy to go through, as a URL |
166
+ | `verify_peer`, `verify_host`, `cafile`, `capath`, `local_cert`, `local_pk`, `passphrase` | how the peer is checked, and what is presented to it |
167
+ | `max_retries` | how many further attempts a client that retries may make |
168
+ | `extra` | options a client may ignore where it has no use for them — `use_persistent_connections`, `trace_content` |
169
+
170
+ A name the contract does not list is refused rather than quietly obeyed, with
171
+ `InvalidArgumentError`:
172
+
173
+ ```
174
+ Unsupported option "timout" passed to "HttpxHttpClient", did you mean "timeout"?
175
+ ```
176
+
177
+ That refusal is the point of naming them: an option spelled wrong cannot pass for one that was
178
+ obeyed. `extra` is the escape hatch for what one client understands and another has no use
179
+ for, so entries in it may be ignored.
180
+
181
+ ## Errors
182
+
183
+ Everything derives from `HttpClientError`, and the shape of the tree is the question a caller
184
+ asks: was there an answer at all, and was it the one wanted?
185
+
186
+ | Error | Raised when |
187
+ |---|---|
188
+ | `HttpClientError` | the root; nothing raises it directly |
189
+ | `TransportError` | no answer came — a refused connection, a broken transfer, a cancelled response |
190
+ | `TransportTimeoutError` | no answer came in time; a `TransportError` |
191
+ | `HttpError` | there is an answer and it is not a success |
192
+ | `RedirectionError` | a 3xx read with `throw`, once `max_redirects` was reached |
193
+ | `ClientError` | a 4xx read with `throw` |
194
+ | `ServerError` | a 5xx read with `throw` |
195
+ | `DecodingError` | the body is not what it was read as |
196
+ | `JsonError` | the body is not the JSON object it was read as; a `DecodingError` |
197
+ | `InvalidArgumentError` | the request is one the client refuses to make, before anything is sent |
198
+
199
+ The message of an `HttpError` names the code and the URL — `HTTP 404 returned for "<url>".` —
200
+ or quotes the status line the server sent, and takes the `title` and `detail` of the body when
201
+ that body is a JSON error document.
202
+
203
+ ## The conformance suite
204
+
205
+ `xtr_http_client_contracts.testing` is how an implementation proves it is one. It ships in
206
+ `src` rather than in `tests` because it is run by other packages; install it with the
207
+ `testing` extra, which brings an async backend, the runner the suite is written for, and a
208
+ message request implementation.
209
+
210
+ Subclass `HttpClientTestCase` and return the client under test:
211
+
212
+ ```python
213
+ # tests/integration/test_my_http_client.py
214
+ from xtr_http_client_contracts import HttpClientInterface
215
+ from xtr_http_client_contracts.testing import HttpClientTestCase
216
+
217
+ from my_package import MyHttpClient
218
+
219
+
220
+ class TestMyHttpClient(HttpClientTestCase):
221
+ def create_client(self) -> HttpClientInterface:
222
+ return MyHttpClient()
223
+ ```
224
+
225
+ That is the whole of it: the suite starts `TestHttpServer` once for the session on a free port,
226
+ points every test at it through `self.base_uri` and `self.port`, and runs the contract as
227
+ behaviour — the status that is an answer, the body that is
228
+ kept and can be read twice, the redirect chain that drops credentials when it leaves the host
229
+ and gets them back when it returns, the timeouts, the proxy settings read from the
230
+ environment, the `resolve` pin, the options `send_request` fills in, and the refusals quoted
231
+ above. A client that passes is one a caller can hold through the interface alone.
232
+
233
+ `create_client` is called per test, freshly: a test may cancel a transfer or exhaust a pool, so
234
+ no two tests share a client. A client that cannot honour part of the contract yet says which
235
+ tests it skips and why, rather than quietly not running them:
236
+
237
+ ```python
238
+ from collections.abc import Mapping
239
+ from typing import ClassVar
240
+
241
+
242
+ class TestMyHttpClient(HttpClientTestCase):
243
+ skipped_tests: ClassVar[Mapping[str, str]] = {
244
+ "test_ipv6_resolve": "no IPv6 in this environment",
245
+ }
246
+ ```
247
+
248
+ A parametrized test is named there without its parameter. `TestHttpServer(host=..., port=...)`
249
+ is usable on its own — as a context manager, or with `start()` and `stop()` — for a test that
250
+ needs a second server of its own. Leave `port` out and it listens on whichever free port the
251
+ operating system gives it, which `server.port` and `server.base_uri` report once it has started;
252
+ two suites running at once never collide.
253
+
254
+ ## Development
255
+
256
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
257
+ `packages/xtr-http-client-contracts`; run the commands below from there. The
258
+ `python-xtr-http-client-contracts` repository is a read-only copy, so send issues and pull
259
+ requests to the monorepo.
260
+
261
+ ```sh
262
+ uv sync --all-extras
263
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
264
+ ```
265
+
266
+ [xtr-http-client](../xtr-http-client) is the implementation, and runs this suite against each
267
+ of its clients.
268
+
269
+ ## License
270
+
271
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,27 @@
1
+ xtr_http_client_contracts/__init__.py,sha256=AKyp64z4nKyMrYZqI97yJBB0eGKC_Rfu34elhvyary8,1414
2
+ xtr_http_client_contracts/exception/__init__.py,sha256=rbcAbtSQsNP_-mSh4MUZ_-poFnumMWqHcgA0hy8Gu8w,1466
3
+ xtr_http_client_contracts/exception/client_error.py,sha256=LEJGmaGlzniMUeQLNEacQd8D6wpC4DoDc4W-2nbifSo,473
4
+ xtr_http_client_contracts/exception/decoding_error.py,sha256=9IcqdBK4CaSpVD1Bf-HxKu_amsZufaA-b0NJKdgpZMM,1034
5
+ xtr_http_client_contracts/exception/http_client_error.py,sha256=KGSG22G0x4T3GNc3UzRYpLHjbfGBUXvaPZY1HuD-uzI,870
6
+ xtr_http_client_contracts/exception/http_error.py,sha256=lNJOZ51Hsr4WQnvSGpWRfdCfvZhbBnEt-Lk3sMGeGyU,2058
7
+ xtr_http_client_contracts/exception/invalid_argument_error.py,sha256=RiJNYu8GQJcCY4xK-mwPXPDysovcnmAAdcUwK_mZPMw,1092
8
+ xtr_http_client_contracts/exception/json_error.py,sha256=dYljsXDoUbq6lb5wRmBOC4NIOt3HO66z9agGg3YjXj4,607
9
+ xtr_http_client_contracts/exception/redirection_error.py,sha256=js7OQ0yYFaUtYM1FkJxuNi_6-xwvbR-Q9lF8hglTjzE,735
10
+ xtr_http_client_contracts/exception/server_error.py,sha256=jHPpelWFDRyrUMUxVIoyjuUZ4dxXCZHSWKN6ABJgRtk,423
11
+ xtr_http_client_contracts/exception/transport_error.py,sha256=7vW5i8sN7T9run_JRO1fllnk251HsQtzRUzFQcW0w2o,1046
12
+ xtr_http_client_contracts/exception/transport_timeout_error.py,sha256=bU9mXh2oAeSfcZzkypJhFjZxKsKrivTlmqOOeq1G8TA,991
13
+ xtr_http_client_contracts/http_client_interface.py,sha256=WtFPjSTgVCdnT33rpGW4iK3eWWnd9DJKTZEMvKGHSkk,4417
14
+ xtr_http_client_contracts/http_response_interface.py,sha256=1dn_S51-8syoA9uWWgRsPFAJomshKBRIMbZoLS0y3PY,5757
15
+ xtr_http_client_contracts/options_defaults.py,sha256=ONXeI96HPKTrxSKfd_FxYMOCPg6914MoRNJGWBOMT9Q,2617
16
+ xtr_http_client_contracts/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ xtr_http_client_contracts/request_options.py,sha256=8CaYajdmdgA50-QP2e1jECObbUoe-fXMPlgMnv5whLg,5184
18
+ xtr_http_client_contracts/testing/__init__.py,sha256=FrExbAxW8mo521ilwfDWA2tvYuohgIC11v3f5lAR5Ck,422
19
+ xtr_http_client_contracts/testing/_fixture_request.py,sha256=zQVELOmLjcbLLFXDqoLyUMytsEIeey8vPrymqN3N7pk,7200
20
+ xtr_http_client_contracts/testing/_fixture_response.py,sha256=D7r40k3Rcel3Yk7SnFT7Ufugn6DRgHrJ0IMwR8xDvpY,2855
21
+ xtr_http_client_contracts/testing/_fixture_routes.py,sha256=KcfLYr-SJkXcQXxM1KY7vITrDMT8tPfumX9RCUWP2Kg,12936
22
+ xtr_http_client_contracts/testing/http_client_test_case.py,sha256=PWrgpmT1OgSFwxqncsNwMZ-xIYUbBU72a9y_GFhFcHw,42346
23
+ xtr_http_client_contracts/testing/test_http_server.py,sha256=HkPDlSTM-9cQr3-3ZQ3aUQ9XwPD3qk1x4DQnJaoWjxE,8301
24
+ xtr_http_client_contracts-4.0.2.dist-info/licenses/LICENSE,sha256=HjmMt8uWRU2yv_ijhX_mqelIvKdeCgg-QlhxyxrwQ3c,1106
25
+ xtr_http_client_contracts-4.0.2.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
26
+ xtr_http_client_contracts-4.0.2.dist-info/METADATA,sha256=Nnnb_Rt_qw0qeu75SsDvC0CLj7x5Tr6VBWQJaYTJHYc,12573
27
+ xtr_http_client_contracts-4.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2018-present Fabien Potencier
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.