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,321 @@
1
+ """What the fixture server answers, route by route.
2
+
3
+ Each route exists to put a client in one awkward position: a redirect it must follow or refuse,
4
+ a length that does not match the body, a body compressed only in name, a transfer that never
5
+ ends. ``_fixture_response`` holds how any of it reaches the wire.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from typing import TYPE_CHECKING, Final
13
+ from urllib.parse import parse_qsl
14
+
15
+ from ._fixture_response import Answer, compact, compressed, head, length, pretty, send
16
+
17
+ if TYPE_CHECKING:
18
+ from asyncio import StreamWriter
19
+ from collections.abc import Awaitable, Callable, Mapping
20
+ from typing import TypeAlias
21
+
22
+ from ._fixture_request import FixtureRequest
23
+
24
+ _Route: TypeAlias = Callable[[FixtureRequest, StreamWriter], Awaitable[None]]
25
+
26
+ __all__ = ["respond"]
27
+
28
+ # The standard library's decoder answers anything at all; saying here what this module asks
29
+ # of it keeps that looseness to one line, guarded by the check in `_json_fields`.
30
+ _decode_object: Final[Callable[[bytes], dict[str, object]]] = json.loads
31
+
32
+ _JSON: Final = ("Content-Type", "application/json")
33
+ _HTML: Final = ("Content-Type", "text/html; charset=UTF-8")
34
+ _TEXT: Final = ("Content-Type", "text/plain")
35
+ _ETAG: Final = ("ETag", '"abc123"')
36
+ _EXPECTED_BASIC: Final = "Basic Zm9vOmJhcg=="
37
+ _HEAD_DELAY: Final = 0.3
38
+ _BODY_DELAY: Final = 0.5
39
+ _HINT_DELAY: Final = 0.001
40
+ _SILENCE: Final = 1.0
41
+ _TICK: Final = 0.0005
42
+
43
+ # The hints and the answer after them are written verbatim: an interim response is not
44
+ # something a client library can be asked to produce, so the test server hands over the bytes.
45
+ _EARLY_HINTS: Final = (
46
+ b"HTTP/1.1 103 Early Hints\r\n"
47
+ b"Link: </style.css>; rel=preload; as=style\r\n"
48
+ b"Link: </script.js>; rel=preload; as=script\r\n"
49
+ b"\r\n"
50
+ )
51
+ _AFTER_HINTS: Final = (
52
+ b"HTTP/1.1 200 OK\r\n"
53
+ b"Date: Fri, 26 May 2017 10:02:11 GMT\r\n"
54
+ b"Content-Length: 13\r\n"
55
+ b"\r\n"
56
+ b"Here the body"
57
+ )
58
+ _CHUNKS: Final = b"8\r\nchunked \r\n5\r\ndata \r\n6\r\nworks!\r\n0\r\n\r\n"
59
+ _TRUNCATED_CHUNKS: Final = b"8\r\nchunked \r\n5\r\ndata \r\n6\r\nw"
60
+ _NOT_GZIP: Final = b"-" * 1000
61
+ _DOCUMENTS: Final = {"documents": [{"id": "/json/1"}, {"id": "/json/2"}, {"id": "/json/3"}]}
62
+
63
+
64
+ def _echoing(status: int, *headers: tuple[str, str]) -> _Route:
65
+ """A route answering ``status``, ``headers`` and the request's own variables as JSON."""
66
+
67
+ async def route(request: FixtureRequest, writer: StreamWriter) -> None:
68
+ body = pretty(request.variables())
69
+ answer = Answer(status, (*headers, _JSON, length(body)))
70
+ await send(writer, head(request, answer), body)
71
+
72
+ return route
73
+
74
+
75
+ def _redirecting(status: int, host: str, path: str) -> _Route:
76
+ """An echoing route that sends the client to ``path`` on this same server, under ``host``.
77
+
78
+ The port comes from the connection, because the server listens wherever it was given a
79
+ free port.
80
+ """
81
+
82
+ async def route(request: FixtureRequest, writer: StreamWriter) -> None:
83
+ location = f"http://{host}:{request.server_port}{path}"
84
+ await _echoing(status, ("Location", location))(request, writer)
85
+
86
+ return route
87
+
88
+
89
+ _PLAIN: Final[_Route] = _echoing(200)
90
+ _MOVED: Final[_Route] = _redirecting(301, "127.0.0.1", "/302")
91
+ _TAGGED: Final[_Route] = _echoing(200, _ETAG)
92
+
93
+
94
+ async def _nothing(request: FixtureRequest, writer: StreamWriter) -> None:
95
+ """An unrouted path answers an empty 200, so a client sees a valid, useless response."""
96
+ await send(writer, head(request, Answer(200, (_HTML, ("Content-Length", "0")))))
97
+
98
+
99
+ async def _root(request: FixtureRequest, writer: StreamWriter) -> None:
100
+ """The variables, gzipped when the request asked for it."""
101
+ body, encoding = compressed(request, pretty(request.variables()))
102
+ answer = Answer(200, (*encoding, _JSON, length(body)))
103
+ await send(writer, head(request, answer), body)
104
+
105
+
106
+ async def _withheld_body(request: FixtureRequest, writer: StreamWriter) -> None:
107
+ """Declares the length of a body a HEAD request will not receive."""
108
+ variables = request.variables()
109
+ body = pretty(variables)
110
+ headers = (("X-Request-Vars", compact(variables)), _JSON, length(body))
111
+ await send(
112
+ writer,
113
+ head(request, Answer(200, headers)),
114
+ b"" if request.method == "HEAD" else body,
115
+ )
116
+
117
+
118
+ async def _early_hints(request: FixtureRequest, writer: StreamWriter) -> None:
119
+ """Hints first, the answer after, so a client must keep reading past a 1xx."""
120
+ del request
121
+ await send(writer, _EARLY_HINTS)
122
+ await asyncio.sleep(_HINT_DELAY)
123
+ await send(writer, _AFTER_HINTS)
124
+
125
+
126
+ async def _gzipped_not_found(request: FixtureRequest, writer: StreamWriter) -> None:
127
+ """A 404 whose head arrives long before its compressed body."""
128
+ body, encoding = compressed(request, b"some text")
129
+ await send(writer, head(request, Answer(404, (_TEXT, *encoding))))
130
+ await asyncio.sleep(_HEAD_DELAY)
131
+ await send(writer, body)
132
+
133
+
134
+ async def _credentialed_redirect(request: FixtureRequest, writer: StreamWriter) -> None:
135
+ """Redirects only to a request carrying the credentials the fixture expects."""
136
+ route = _MOVED if request.header("authorization") == _EXPECTED_BASIC else _PLAIN
137
+ await route(request, writer)
138
+
139
+
140
+ async def _found(request: FixtureRequest, writer: StreamWriter) -> None:
141
+ """Redirects until a request arrives with credentials, which ends the chain."""
142
+ if request.header("authorization") is not None:
143
+ await _PLAIN(request, writer)
144
+ return
145
+ targets = [value for name, value in _parameters(request) if name == "location"]
146
+ location = targets[-1] if targets else f"http://localhost:{request.server_port}/"
147
+ await _echoing(302, ("Location", location))(request, writer)
148
+
149
+
150
+ async def _not_modified(request: FixtureRequest, writer: StreamWriter) -> None:
151
+ """Declares ten bytes and sends five, under a status allowed no body at all."""
152
+ await send(writer, head(request, Answer(304, (("Content-Length", "10"),))), b"12345")
153
+
154
+
155
+ async def _etag_route(request: FixtureRequest, writer: StreamWriter) -> None:
156
+ """Answers the variables, or nothing at all to a request that already has them."""
157
+ if request.header("if-none-match") is None:
158
+ await _TAGGED(request, writer)
159
+ return
160
+ await send(writer, head(request, Answer(304, (_ETAG,))))
161
+
162
+
163
+ async def _broken_length(request: FixtureRequest, writer: StreamWriter) -> None:
164
+ """Declares a length the body never reaches, so a client waits for bytes that never come."""
165
+ body = pretty(request.variables())
166
+ answer = Answer(200, (("Content-Length", "1000"), _JSON))
167
+ await send(writer, head(request, answer), body)
168
+
169
+
170
+ async def _echo_posted(request: FixtureRequest, writer: StreamWriter) -> None:
171
+ """Answers what was sent: a form's fields, or a JSON body's keys and its content type."""
172
+ body = pretty(_posted(request))
173
+ answer = Answer(200, (_JSON, length(body)))
174
+ await send(writer, head(request, answer), body)
175
+
176
+
177
+ async def _slow_head(request: FixtureRequest, writer: StreamWriter) -> None:
178
+ """Says nothing at all for long enough to trip a timeout before the status arrives."""
179
+ await asyncio.sleep(_HEAD_DELAY)
180
+ await _PLAIN(request, writer)
181
+
182
+
183
+ async def _slow_body(request: FixtureRequest, writer: StreamWriter) -> None:
184
+ """Answers, then pauses mid-body long enough to trip a timeout between chunks."""
185
+ await send(writer, head(request, Answer(200, (_HTML,))), b"<1>")
186
+ await asyncio.sleep(_BODY_DELAY)
187
+ await send(writer, b"<2>")
188
+
189
+
190
+ async def _endless(request: FixtureRequest, writer: StreamWriter) -> None:
191
+ """Writes until the client goes away, so only a deadline ends the transfer."""
192
+ await send(writer, head(request, Answer(200, (_HTML,))), b"<1>")
193
+ while not writer.is_closing():
194
+ await asyncio.sleep(_TICK)
195
+ await send(writer, b"<1>")
196
+
197
+
198
+ async def _silent_then_endless(request: FixtureRequest, writer: StreamWriter) -> None:
199
+ """Silent for a second, then endless: a deadline may fall on either side of the status."""
200
+ await asyncio.sleep(_SILENCE)
201
+ await _endless(request, writer)
202
+
203
+
204
+ async def _chunked(request: FixtureRequest, writer: StreamWriter) -> None:
205
+ """A body framed in three chunks, as a client reading chunk by chunk should see it."""
206
+ answer = Answer(200, (_HTML, ("Transfer-Encoding", "chunked")))
207
+ await send(writer, head(request, answer), _CHUNKS)
208
+
209
+
210
+ async def _chunked_broken(request: FixtureRequest, writer: StreamWriter) -> None:
211
+ """Stops inside the third chunk, leaving the framing unfinished."""
212
+ answer = Answer(200, (_HTML, ("Transfer-Encoding", "chunked")))
213
+ await send(writer, head(request, answer), _TRUNCATED_CHUNKS)
214
+
215
+
216
+ async def _gzip_broken(request: FixtureRequest, writer: StreamWriter) -> None:
217
+ """Announces gzip and sends something else, so decoding must fail, not the transfer."""
218
+ answer = Answer(200, (_HTML, ("Content-Encoding", "gzip"), length(_NOT_GZIP)))
219
+ await send(writer, head(request, answer), _NOT_GZIP)
220
+
221
+
222
+ async def _json_index(request: FixtureRequest, writer: StreamWriter) -> None:
223
+ """Three document ids, for a client fetching them one after another."""
224
+ body = compact(_DOCUMENTS).encode()
225
+ await send(writer, head(request, Answer(200, (_JSON, length(body)))), body)
226
+
227
+
228
+ async def _json_document(request: FixtureRequest, writer: StreamWriter) -> None:
229
+ """A document titled after the target it was asked for."""
230
+ body = compact({"title": request.target}).encode()
231
+ await send(writer, head(request, Answer(200, (_JSON, length(body)))), body)
232
+
233
+
234
+ async def _custom(request: FixtureRequest, writer: StreamWriter) -> None:
235
+ """Answers the status and headers the query string asks for."""
236
+ status = 200
237
+ headers: list[tuple[str, str]] = []
238
+ for name, value in _parameters(request):
239
+ if name == "status" and value.isdigit():
240
+ status = int(value)
241
+ elif name == "headers[]":
242
+ field, _, content = value.partition(":")
243
+ # The echo sets its own content type last, so asking for another is pointless.
244
+ if field.strip().lower() != "content-type":
245
+ headers.append((field.strip(), content.strip()))
246
+ await _echoing(status, *headers)(request, writer)
247
+
248
+
249
+ def _parameters(request: FixtureRequest) -> list[tuple[str, str]]:
250
+ return parse_qsl(request.query, keep_blank_values=True)
251
+
252
+
253
+ def _posted(request: FixtureRequest) -> dict[str, object]:
254
+ """The fields a request carried, named as the echo answers them.
255
+
256
+ A form is answered as its fields. Anything else is read as JSON, and the content type is
257
+ answered alongside its keys — ``?`` when the request named none, so a client can tell a
258
+ missing header from an empty one. The method is answered last, unless a field claimed
259
+ the name first.
260
+ """
261
+ fields = _form_fields(request)
262
+ if not fields:
263
+ fields = _json_fields(request.body)
264
+ fields["content-type"] = request.header("content-type") or "?"
265
+ if "REQUEST_METHOD" not in fields:
266
+ fields["REQUEST_METHOD"] = request.method
267
+ return fields
268
+
269
+
270
+ def _form_fields(request: FixtureRequest) -> dict[str, object]:
271
+ content_type = request.header("content-type") or ""
272
+ if request.method != "POST" or not content_type.startswith("application/x-www-form-urlencoded"):
273
+ return {}
274
+ return dict(parse_qsl(request.body.decode("latin-1")))
275
+
276
+
277
+ def _json_fields(body: bytes) -> dict[str, object]:
278
+ """The keys of a JSON object body; anything else carries no fields to echo."""
279
+ if not body.lstrip().startswith(b"{"):
280
+ return {}
281
+ try:
282
+ return _decode_object(body)
283
+ except ValueError:
284
+ return {}
285
+
286
+
287
+ _ROUTES: Final[Mapping[str, _Route]] = {
288
+ "/": _root,
289
+ "/103": _early_hints,
290
+ "/301": _credentialed_redirect,
291
+ "/301/bad-tld": _echoing(301, ("Location", "http://foo.example.")),
292
+ "/301/invalid": _echoing(301, ("Location", "//?foo=bar")),
293
+ "/301/proxy": _redirecting(301, "localhost", "/"),
294
+ "/302": _found,
295
+ "/302/relative": _echoing(302, ("Location", "..")),
296
+ "/304": _not_modified,
297
+ "/304/etag": _etag_route,
298
+ "/307": _redirecting(307, "localhost", "/post"),
299
+ "/404": _echoing(404),
300
+ "/404-gzipped": _gzipped_not_found,
301
+ "/chunked": _chunked,
302
+ "/chunked-broken": _chunked_broken,
303
+ "/custom": _custom,
304
+ "/gzip-broken": _gzip_broken,
305
+ "/head": _withheld_body,
306
+ "/json": _json_index,
307
+ "/json/1": _json_document,
308
+ "/json/2": _json_document,
309
+ "/json/3": _json_document,
310
+ "/length-broken": _broken_length,
311
+ "/max-duration": _endless,
312
+ "/post": _echo_posted,
313
+ "/timeout-body": _slow_body,
314
+ "/timeout-header": _slow_head,
315
+ "/timeout-long": _silent_then_endless,
316
+ }
317
+
318
+
319
+ async def respond(request: FixtureRequest, writer: StreamWriter) -> None:
320
+ """Answer ``request`` on ``writer``, dispatching on the target's path."""
321
+ await _ROUTES.get(request.path, _nothing)(request, writer)