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,1035 @@
1
+ """The conformance suite every client implementation runs against itself.
2
+
3
+ Subclass :class:`HttpClientTestCase`, return a client from ``create_client``, and the whole
4
+ suite runs against the fixture server. Nothing here is specific to one client: every assertion
5
+ is something the contract promises, so a client that passes is one a caller can hold through
6
+ the interface alone.
7
+
8
+ The suite is one class on purpose — it is the contract read as behaviour, and a reader checking
9
+ whether their client conforms reads it top to bottom. Splitting it by topic would only move the
10
+ reading around. (allow: SIZE_OK — one conformance suite, one file.)
11
+
12
+ What came from the reference suite, test by test. "ported" means the same calls and the same
13
+ assertions; "translated" says what had to change for an async client whose ``request()``
14
+ returns once the status line and the headers are in; "removed" names the feature the contract
15
+ dropped, which took the test with it.
16
+
17
+ ================================== ==========================================================
18
+ ``testGetRequest`` translated: eager request
19
+ ``testHeadRequest`` translated: eager request, and the body is always kept
20
+ ``testNonBufferedGetRequest`` removed: the body is always kept
21
+ ``testBufferSink`` no counterpart: the sink is a writable stream resource,
22
+ which an async client has no equivalent of
23
+ ``testConditionalBuffering`` removed: the body is always kept
24
+ ``testReentrantBufferCallback`` removed: the body is always kept
25
+ ``testThrowingBufferCallback`` removed: the body is always kept
26
+ ``testUnsupportedOption`` translated: an unsupported option is a ``TransportError``
27
+ ``testHttpVersion`` translated: ``http_version`` is the version as text
28
+ ``testChunkedEncoding`` translated: the fixture's chunked body
29
+ ``testClientError`` translated: eager request, without the streamed half
30
+ ``testIgnoreErrors`` ported
31
+ ``testDnsError`` translated: eager request
32
+ ``testInlineAuth`` translated: the fixture's ``AUTH_USER`` / ``AUTH_PW``
33
+ ``testBadRequestBody`` translated: eager request
34
+ ``test304`` translated: the body is always kept
35
+ ``testRedirects`` translated: parametrized, and the header filter names
36
+ ``Content-Type`` because the fixture declares a length
37
+ ``testInvalidRedirect`` translated: the status is raised by reading the content
38
+ ``testRelativeRedirects`` ported
39
+ ``testRedirect307`` ported
40
+ ``testMaxRedirects`` translated: the status is raised by reading the content
41
+ ``testStream`` removed: no chunk streaming
42
+ ``testAddToStream`` removed: no chunk streaming
43
+ ``testCompleteTypeError`` removed: no chunk streaming
44
+ ``testOnProgress`` removed: no progress callback
45
+ ``testPostJson`` ported
46
+ ``testPostArray`` ported
47
+ ``testPostResource`` translated: an ``AsyncIterable[bytes]`` body
48
+ ``testPostCallback`` translated: an ``AsyncIterable[bytes]`` body
49
+ ``testCancel`` ported
50
+ ``testInfoOnCanceledResponse`` ported
51
+ ``testCancelInStream`` removed: no chunk streaming
52
+ ``testOnProgressCancel`` removed: no progress callback
53
+ ``testOnProgressError`` removed: no progress callback
54
+ ``testResolve`` translated: eager request
55
+ ``testIdnResolve`` ported
56
+ ``testIPv6Resolve`` translated: a second server, started by the test
57
+ ``testNotATimeout`` ported
58
+ ``testTimeoutOnAccess`` translated: eager request
59
+ ``testTimeoutIsNotAFatalError`` ported
60
+ ``testTimeoutOnStream`` removed: no chunk streaming
61
+ ``testUncheckedTimeoutThrows`` removed: no chunk streaming
62
+ ``testTimeoutWithActiveConcurrentStream`` translated: two servers, started by the test
63
+ ``testTimeoutOnInitialize`` translated: eager request
64
+ ``testTimeoutOnDestruct`` translated: eager request — the timeouts are reported
65
+ where the requests are made, not where they are released
66
+ ``testDestruct`` translated: the ``async with`` exit frees the transfer
67
+ ``testGetContentAfterDestruct`` removed: releasing a response raises no status
68
+ ``testGetEncodedContentAfterDestruct`` removed: releasing a response raises no status
69
+ ``testProxy`` translated: the ``proxy`` option alone
70
+ ``testNoProxy`` translated: ``http_proxy`` and ``no_proxy``, patched into
71
+ the environment rather than written to it
72
+ ``testAutoEncodingRequest`` ported
73
+ ``testBaseUri`` ported
74
+ ``testQuery`` ported
75
+ ``testInformationalResponse`` ported
76
+ ``testInformationalResponseStream`` removed: no chunk streaming
77
+ ``testUserlandEncodingRequest`` ported
78
+ ``testGzipBroken`` ported
79
+ ``testMaxDuration`` ported
80
+ ``testMaxConnectDurationInfo`` removed: the connect limit is not in the transfer info
81
+ ``testMaxConnectDuration`` translated: eager request
82
+ ``testWithOptions`` ported
83
+ ``testBindToPort`` removed: no ``bindto`` option
84
+ ``testBindToPortV6`` removed: no ``bindto`` option
85
+ ================================== ==========================================================
86
+
87
+ 63 reference methods: 43 here, 19 removed with the features they test, 1 with no counterpart.
88
+
89
+ Seven tests are the contract's own additions, with nothing behind them in the reference suite:
90
+
91
+ ``test_body_stream``
92
+ Reads the body through the message's stream rather than in one go.
93
+ ``test_send_request_prefers_the_request_headers``
94
+ A header the request carries wins; the options fill the names it lacks.
95
+ ``test_send_request_resolves_a_relative_request_uri``
96
+ A relative request URI is resolved against ``base_uri``.
97
+ ``test_send_request_merges_the_query_option``
98
+ ``query`` is merged into the URI the request came with.
99
+ ``test_send_request_applies_auth_bearer_only_without_one``
100
+ ``auth_bearer`` fills an Authorization the request has none of, and replaces none.
101
+ ``test_send_request_refuses_a_body_on_both``
102
+ A request with a body plus a ``body`` option is refused, word for word.
103
+ ``test_request_is_send_request_of_the_same_request``
104
+ ``request()`` answers as ``send_request`` of the same request does.
105
+ """
106
+
107
+ from __future__ import annotations
108
+
109
+ import gzip
110
+ import json
111
+ import re
112
+ import time
113
+ from collections.abc import Mapping
114
+ from typing import TYPE_CHECKING, ClassVar, Protocol, TypeVar, cast
115
+
116
+ import anyio
117
+ import pytest
118
+ from xtr_http_message import Request
119
+
120
+ from xtr_http_client_contracts.exception import (
121
+ ClientError,
122
+ InvalidArgumentError,
123
+ RedirectionError,
124
+ TransportError,
125
+ TransportTimeoutError,
126
+ )
127
+
128
+ from .test_http_server import TestHttpServer
129
+
130
+ if TYPE_CHECKING:
131
+ from collections.abc import AsyncIterator, Iterator, Sequence
132
+
133
+ from xtr_http_client_contracts.http_client_interface import HttpClientInterface
134
+ from xtr_http_client_contracts.http_response_interface import HttpResponseInterface
135
+ from xtr_http_client_contracts.request_options import RequestOptions
136
+
137
+ __all__ = ["HttpClientTestCase"]
138
+
139
+ _T = TypeVar("_T")
140
+
141
+ _HOST = "localhost"
142
+ _IPV6_HOST = "::1"
143
+
144
+ # A name reserved for documentation, pinned to the fixture server by the `resolve` option.
145
+ _PINNED_HOST = "pinned.example"
146
+
147
+ # The hops of a redirect chain, as the reference suite reads them out of `response_headers`:
148
+ # the status lines, where each hop pointed, and what each hop answered with. The fixture's
149
+ # echo routes declare a length, so the filter names `Content-Type` rather than any `Cont`
150
+ # header.
151
+ _CHAIN_FIELDS = ("HTTP", "Location:", "Content-Type:")
152
+
153
+ # The echo entry no two connections agree on: the port the client's end of the connection
154
+ # was given, which the operating system picks afresh every time.
155
+ _CLIENT_PORT = "REMOTE_PORT"
156
+
157
+ # Matched word for word, because the contract quotes this refusal rather than describing it.
158
+ _BOTH_BODIES = (
159
+ 'Define either the request body or the "body" or "json" option, setting both is not supported.'
160
+ )
161
+
162
+
163
+ class _NamedNode(Protocol):
164
+ """The collected test, as much of it as the skip list needs."""
165
+
166
+ name: str
167
+
168
+
169
+ class _RunningTest(Protocol):
170
+ """What the runner tells a fixture about the test it is running for.
171
+
172
+ The runner leaves this untyped, and the suite will not loosen its own checking to read
173
+ one string: this says what is actually there and nothing else.
174
+ """
175
+
176
+ @property
177
+ def node(self) -> _NamedNode: ...
178
+
179
+
180
+ def _decode_object(content: bytes) -> Mapping[str, object]:
181
+ """``content`` read as the JSON object it is.
182
+
183
+ The standard library's decoder answers anything at all; saying here what the suite asks of
184
+ it keeps that looseness to one place.
185
+ """
186
+ return cast("Mapping[str, object]", cast("object", json.loads(content)))
187
+
188
+
189
+ def _without_client_port(echo: Mapping[str, object]) -> Mapping[str, object]:
190
+ """``echo`` without the port the request came from.
191
+
192
+ Every connection is given a port of its own, so two echoes of the same request differ
193
+ there and nowhere else; dropping it is what leaves the rest comparable.
194
+ """
195
+ return {name: value for name, value in echo.items() if name != _CLIENT_PORT}
196
+
197
+
198
+ def _expect(value: object, kind: type[_T]) -> _T:
199
+ """``value``, once it is a ``kind``; the transfer info comes out of the contract untyped."""
200
+ if not isinstance(value, kind):
201
+ raise TypeError(f"expected {kind.__name__}, got {value!r}")
202
+ return value
203
+
204
+
205
+ def _info(response: HttpResponseInterface, key: str, kind: type[_T]) -> _T:
206
+ """The ``key`` entry of ``response``'s info, narrowed to ``kind``."""
207
+ return _expect(response.get_info(key), kind)
208
+
209
+
210
+ def _info_map(response: HttpResponseInterface) -> Mapping[str, object]:
211
+ """Every entry of ``response``'s info at once."""
212
+ return cast("Mapping[str, object]", _expect(response.get_info(), Mapping))
213
+
214
+
215
+ def _info_lines(response: HttpResponseInterface) -> list[str]:
216
+ """The ``response_headers`` entry, as the lines it holds."""
217
+ lines = cast("Sequence[object]", response.get_info("response_headers"))
218
+ return [_expect(line, str) for line in lines]
219
+
220
+
221
+ def _chain(response: HttpResponseInterface) -> list[str]:
222
+ """Where every hop of a redirect chain went, and what it answered with."""
223
+ return [line for line in _info_lines(response) if line.startswith(_CHAIN_FIELDS)]
224
+
225
+
226
+ def _header(response: HttpResponseInterface, name: str) -> Sequence[str]:
227
+ """Every value under ``name``, matched without regard to case."""
228
+ wanted = name.lower()
229
+ headers = response.get_headers()
230
+ for key, values in headers.items():
231
+ if key.lower() == wanted:
232
+ return values
233
+ raise AssertionError(f"no {name} header among {sorted(headers)}")
234
+
235
+
236
+ async def _parts(*parts: bytes) -> AsyncIterator[bytes]:
237
+ """``parts`` as a body produced piece by piece."""
238
+ for part in parts:
239
+ yield part
240
+
241
+
242
+ async def _not_bytes() -> AsyncIterator[bytes]:
243
+ """A body yielding something that is not bytes, which no client can send."""
244
+ yield cast("bytes", cast("object", []))
245
+
246
+
247
+ class HttpClientTestCase:
248
+ """What every client that satisfies the contract does, run against the fixture server.
249
+
250
+ Subclass it and return the client under test::
251
+
252
+ class TestMyClient(HttpClientTestCase):
253
+ def create_client(self) -> HttpClientInterface:
254
+ return MyClient()
255
+
256
+ A client that cannot honour part of the contract yet names the tests it skips and why::
257
+
258
+ class TestMyClient(HttpClientTestCase):
259
+ skipped_tests: ClassVar[Mapping[str, str]] = {
260
+ "test_ipv6_resolve": "no IPv6 in this environment",
261
+ }
262
+
263
+ The suite starts the fixture server once for the whole session, on a free port, and
264
+ points every test at it through :attr:`base_uri` and :attr:`port`; the few tests that need
265
+ a second server start and stop one themselves. A subclass adding tests of its own reads
266
+ the same two attributes.
267
+ """
268
+
269
+ __test__: ClassVar[bool] = False # the suite is a base class, not a test case of its own
270
+
271
+ pytestmark: ClassVar[pytest.MarkDecorator] = pytest.mark.anyio
272
+
273
+ skipped_tests: ClassVar[Mapping[str, str]] = {}
274
+ """Test names to skip, mapped to why. A parametrized test is named without its parameter."""
275
+
276
+ port: int = 0
277
+ """The port the session's fixture server listens on, set before every test."""
278
+
279
+ base_uri: str = ""
280
+ """The origin of the session's fixture server, set before every test."""
281
+
282
+ @pytest.fixture(scope="session", autouse=True)
283
+ @staticmethod
284
+ def fixture_server() -> Iterator[TestHttpServer]:
285
+ """The server every test points at, listening for the whole session."""
286
+ with TestHttpServer() as server:
287
+ yield server
288
+
289
+ @pytest.fixture(autouse=True)
290
+ def _point_at_fixture_server(self, fixture_server: TestHttpServer) -> None:
291
+ """Aim this test at wherever the session's fixture server was given a port."""
292
+ self.port = fixture_server.port
293
+ self.base_uri = f"http://{_HOST}:{self.port}"
294
+
295
+ @pytest.fixture(autouse=True)
296
+ def _skip_if_unsupported(self, request: pytest.FixtureRequest) -> None:
297
+ """Skip a test this client has declared it does not satisfy."""
298
+ running = cast("_RunningTest", cast("object", request))
299
+ reason = self.skipped_tests.get(running.node.name.partition("[")[0])
300
+ if reason is not None:
301
+ pytest.skip(reason)
302
+
303
+ def create_client(self) -> HttpClientInterface:
304
+ """Return the client the suite is to run against.
305
+
306
+ Returns:
307
+ A client, freshly made: a test may cancel transfers or exhaust a connection pool,
308
+ so no two tests share one.
309
+ """
310
+ raise NotImplementedError("a subclass returns the client under test")
311
+
312
+ async def test_get_request(self) -> None:
313
+ """A plain GET: the status and headers are in, and the body decodes to what was sent."""
314
+ client = self.create_client()
315
+ data = object()
316
+ response = await client.request(
317
+ "GET",
318
+ self.base_uri,
319
+ headers={"Foo": "baR"},
320
+ user_data=data,
321
+ )
322
+
323
+ assert _info_lines(response)
324
+ assert _info_map(response)["user_data"] is data
325
+ assert response.get_status_code() == 200
326
+
327
+ info = _info_map(response)
328
+ assert info["error"] is None
329
+ assert info["redirect_count"] == 0
330
+ assert _info_lines(response)[0] == "HTTP/1.1 200 OK"
331
+ assert info["url"] == f"{self.base_uri}/"
332
+
333
+ assert _header(response, "content-type") == ["application/json"]
334
+
335
+ body = _decode_object(await response.get_content())
336
+ assert body == await response.to_array()
337
+
338
+ assert body["SERVER_PROTOCOL"] == "HTTP/1.1"
339
+ assert body["REQUEST_URI"] == "/"
340
+ assert body["REQUEST_METHOD"] == "GET"
341
+ assert body["HTTP_HOST"] == f"{_HOST}:{self.port}"
342
+ assert body["HTTP_FOO"] == "baR"
343
+
344
+ broken = await client.request("GET", f"{self.base_uri}/length-broken")
345
+
346
+ with pytest.raises(TransportError):
347
+ _ = await broken.get_content()
348
+
349
+ async def test_body_stream(self) -> None:
350
+ """The body is also the message's stream, which reads the same bytes.
351
+
352
+ Nothing in the reference suite reads a response that way — it has no streamed body
353
+ on the message — so this is the contract's own addition.
354
+ """
355
+ client = self.create_client()
356
+ response = await client.request("GET", self.base_uri, headers={"Foo": "baR"})
357
+
358
+ content = await response.get_content()
359
+ body = response.get_body()
360
+
361
+ assert await body.get_contents() == content
362
+ await body.rewind()
363
+ assert await body.read(len(content)) == content
364
+
365
+ async def test_head_request(self) -> None:
366
+ """A HEAD answers headers describing a body it does not send."""
367
+ client = self.create_client()
368
+ response = await client.request("HEAD", f"{self.base_uri}/head", headers={"Foo": "baR"})
369
+
370
+ assert _info_lines(response)
371
+ assert response.get_status_code() == 200
372
+ assert _info_lines(response)[0] == "HTTP/1.1 200 OK"
373
+
374
+ assert _header(response, "content-type") == ["application/json"]
375
+ assert int(_header(response, "content-length")[0]) > 0
376
+
377
+ assert await response.get_content() == b""
378
+
379
+ async def test_unsupported_option(self) -> None:
380
+ """An option the client has never heard of is refused, never quietly obeyed."""
381
+ client = self.create_client()
382
+ unknown = cast("RequestOptions", cast("object", {"capture_peer_cert": 1.0}))
383
+
384
+ with pytest.raises(TransportError):
385
+ _ = await client.request("GET", self.base_uri, **unknown)
386
+
387
+ async def test_http_version(self) -> None:
388
+ """Asking for HTTP/1.0 sends HTTP/1.0, which the echo answers with."""
389
+ client = self.create_client()
390
+ response = await client.request("GET", self.base_uri, http_version="1.0")
391
+
392
+ assert response.get_status_code() == 200
393
+ assert _info_lines(response)[0] == "HTTP/1.0 200 OK"
394
+
395
+ body = await response.to_array()
396
+
397
+ assert body["SERVER_PROTOCOL"] == "HTTP/1.0"
398
+ assert body["REQUEST_METHOD"] == "GET"
399
+ assert body["REQUEST_URI"] == "/"
400
+
401
+ async def test_chunked_encoding(self) -> None:
402
+ """A chunked body is reassembled; framing that stops mid-chunk fails the transfer."""
403
+ client = self.create_client()
404
+ response = await client.request("GET", f"{self.base_uri}/chunked")
405
+
406
+ assert _header(response, "transfer-encoding") == ["chunked"]
407
+ assert await response.get_content() == b"chunked data works!"
408
+
409
+ response = await client.request("GET", f"{self.base_uri}/chunked-broken")
410
+
411
+ with pytest.raises(TransportError):
412
+ _ = await response.get_content()
413
+
414
+ async def test_client_error(self) -> None:
415
+ """A 404 is an answer: told about when asked for, readable when not."""
416
+ client = self.create_client()
417
+ response = await client.request("GET", f"{self.base_uri}/404")
418
+
419
+ assert _info(response, "http_code", int) == 404
420
+
421
+ with pytest.raises(ClientError):
422
+ _ = await response.get_content()
423
+
424
+ assert response.get_status_code() == 404
425
+ assert _header(response, "content-type") == ["application/json"]
426
+ assert await response.get_content(throw=False)
427
+
428
+ async def test_ignore_errors(self) -> None:
429
+ """Reading the status of a 404 is not being told about it."""
430
+ client = self.create_client()
431
+ response = await client.request("GET", f"{self.base_uri}/404")
432
+
433
+ assert response.get_status_code() == 404
434
+
435
+ async def test_dns_error(self) -> None:
436
+ """A host that will not resolve fails the request, and fails it again on a retry."""
437
+ client = self.create_client()
438
+
439
+ with pytest.raises(TransportError):
440
+ _ = await client.request("GET", f"{self.base_uri}/301/bad-tld")
441
+
442
+ with pytest.raises(TransportError):
443
+ _ = await client.request("GET", f"{self.base_uri}/301/bad-tld")
444
+
445
+ async def test_inline_auth(self) -> None:
446
+ """Credentials in the URL are sent as basic credentials, decoded as given."""
447
+ client = self.create_client()
448
+ response = await client.request("GET", f"http://foo:bar%3Dbar@{_HOST}:{self.port}")
449
+
450
+ body = await response.to_array()
451
+
452
+ assert body["AUTH_USER"] == "foo"
453
+ assert body["AUTH_PW"] == "bar=bar"
454
+
455
+ async def test_bad_request_body(self) -> None:
456
+ """A body producing something that is not bytes fails the request."""
457
+ client = self.create_client()
458
+
459
+ with pytest.raises(TransportError):
460
+ _ = await client.request("POST", f"{self.base_uri}/", body=_not_bytes())
461
+
462
+ async def test_304(self) -> None:
463
+ """A 304 carries no body, whatever length it declares."""
464
+ client = self.create_client()
465
+ response = await client.request(
466
+ "GET", f"{self.base_uri}/304", headers={"If-Match": '"abc"'}
467
+ )
468
+
469
+ assert response.get_status_code() == 304
470
+ assert await response.get_content(throw=False) == b""
471
+
472
+ @pytest.mark.parametrize("headers", [{}, {"Content-Length": "7"}])
473
+ async def test_redirects(self, headers: Mapping[str, str]) -> None:
474
+ """A chain is followed as GET, and credentials come back when it returns home."""
475
+ client = self.create_client()
476
+ response = await client.request(
477
+ "POST",
478
+ f"{self.base_uri}/301",
479
+ auth_basic="foo:bar",
480
+ headers=headers,
481
+ body=_parts(b"foo=bar"),
482
+ )
483
+
484
+ body = await response.to_array()
485
+ assert body["REQUEST_METHOD"] == "GET"
486
+ assert body["HTTP_AUTHORIZATION"] == "Basic Zm9vOmJhcg=="
487
+ assert _info(response, "url", str) == f"{self.base_uri}/"
488
+
489
+ assert _info(response, "redirect_count", int) == 2
490
+ assert response.get_info("redirect_url") is None
491
+
492
+ assert _chain(response) == [
493
+ "HTTP/1.1 301 Moved Permanently",
494
+ f"Location: http://127.0.0.1:{self.port}/302",
495
+ "Content-Type: application/json",
496
+ "HTTP/1.1 302 Found",
497
+ f"Location: {self.base_uri}/",
498
+ "Content-Type: application/json",
499
+ "HTTP/1.1 200 OK",
500
+ "Content-Type: application/json",
501
+ ]
502
+
503
+ async def test_invalid_redirect(self) -> None:
504
+ """A Location nothing can be made of is left as the 3xx it came on."""
505
+ client = self.create_client()
506
+ response = await client.request("GET", f"{self.base_uri}/301/invalid")
507
+
508
+ assert response.get_status_code() == 301
509
+ assert _header(response, "location") == ["//?foo=bar"]
510
+ assert _info(response, "redirect_count", int) == 0
511
+ assert response.get_info("redirect_url") is None
512
+
513
+ with pytest.raises(RedirectionError):
514
+ _ = await response.get_content()
515
+
516
+ async def test_relative_redirects(self) -> None:
517
+ """A relative Location is resolved; following none leaves it to be read."""
518
+ client = self.create_client()
519
+ response = await client.request("GET", f"{self.base_uri}/302/relative")
520
+
521
+ body = await response.to_array()
522
+
523
+ assert body["REQUEST_URI"] == "/"
524
+ assert response.get_info("redirect_url") is None
525
+
526
+ response = await client.request("GET", f"{self.base_uri}/302/relative", max_redirects=0)
527
+
528
+ assert response.get_status_code() == 302
529
+ assert _info(response, "redirect_url", str) == f"{self.base_uri}/"
530
+
531
+ async def test_redirect_307(self) -> None:
532
+ """A 307 keeps the method and the body, once it is followed."""
533
+ client = self.create_client()
534
+
535
+ response = await client.request(
536
+ "POST",
537
+ f"{self.base_uri}/307",
538
+ body=_parts(b"foo=bar"),
539
+ max_redirects=0,
540
+ )
541
+
542
+ assert response.get_status_code() == 307
543
+
544
+ response = await client.request("POST", f"{self.base_uri}/307", body="foo=bar")
545
+
546
+ assert await response.to_array() == {"foo": "bar", "REQUEST_METHOD": "POST"}
547
+
548
+ async def test_max_redirects(self) -> None:
549
+ """One hop too few ends on the 3xx, saying where it would have gone."""
550
+ client = self.create_client()
551
+ response = await client.request(
552
+ "GET",
553
+ f"{self.base_uri}/301",
554
+ max_redirects=1,
555
+ auth_basic="foo:bar",
556
+ )
557
+
558
+ with pytest.raises(RedirectionError):
559
+ _ = await response.get_content()
560
+
561
+ assert response.get_status_code() == 302
562
+ assert _info(response, "redirect_count", int) == 1
563
+ assert _info(response, "redirect_url", str) == f"{self.base_uri}/"
564
+
565
+ assert _chain(response) == [
566
+ "HTTP/1.1 301 Moved Permanently",
567
+ f"Location: http://127.0.0.1:{self.port}/302",
568
+ "Content-Type: application/json",
569
+ "HTTP/1.1 302 Found",
570
+ f"Location: {self.base_uri}/",
571
+ "Content-Type: application/json",
572
+ ]
573
+
574
+ async def test_post_json(self) -> None:
575
+ """A JSON body is sent as JSON, and says so in its content type."""
576
+ client = self.create_client()
577
+
578
+ response = await client.request("POST", f"{self.base_uri}/post", json={"foo": "bar"})
579
+
580
+ body = dict(await response.to_array())
581
+
582
+ assert "json" in _expect(body.pop("content-type"), str)
583
+ assert body == {"foo": "bar", "REQUEST_METHOD": "POST"}
584
+
585
+ async def test_post_array(self) -> None:
586
+ """A mapping body is sent as a form, which the echo answers field by field."""
587
+ client = self.create_client()
588
+
589
+ response = await client.request("POST", f"{self.base_uri}/post", body={"foo": "bar"})
590
+
591
+ assert await response.to_array() == {"foo": "bar", "REQUEST_METHOD": "POST"}
592
+
593
+ async def test_post_resource(self) -> None:
594
+ """A body read from somewhere else is sent as it is produced."""
595
+ client = self.create_client()
596
+
597
+ response = await client.request(
598
+ "POST",
599
+ f"{self.base_uri}/post",
600
+ body=_parts(b"foo=0123456789"),
601
+ )
602
+
603
+ body = await response.to_array()
604
+
605
+ assert body == {"foo": "0123456789", "REQUEST_METHOD": "POST"}
606
+
607
+ async def test_post_callback(self) -> None:
608
+ """A body produced in pieces, empty ones included, arrives as one form."""
609
+ client = self.create_client()
610
+
611
+ response = await client.request(
612
+ "POST",
613
+ f"{self.base_uri}/post",
614
+ body=_parts(b"foo", b"", b"=", b"0123456789"),
615
+ )
616
+
617
+ assert await response.to_array() == {"foo": "0123456789", "REQUEST_METHOD": "POST"}
618
+
619
+ async def test_cancel(self) -> None:
620
+ """A cancelled transfer has nothing left to read."""
621
+ client = self.create_client()
622
+ response = await client.request("GET", f"{self.base_uri}/timeout-header")
623
+
624
+ await response.cancel()
625
+
626
+ with pytest.raises(TransportError):
627
+ _ = await response.get_content()
628
+
629
+ async def test_info_on_canceled_response(self) -> None:
630
+ """Cancelling is something the transfer info records."""
631
+ client = self.create_client()
632
+
633
+ response = await client.request("GET", f"{self.base_uri}/timeout-header")
634
+
635
+ assert _info(response, "canceled", bool) is False
636
+ await response.cancel()
637
+ assert _info(response, "canceled", bool) is True
638
+
639
+ async def test_resolve(self) -> None:
640
+ """A host pinned to an address is reached at it, and is not once the pin is gone."""
641
+ client = self.create_client()
642
+ response = await client.request(
643
+ "GET",
644
+ f"http://{_PINNED_HOST}:{self.port}/",
645
+ resolve={_PINNED_HOST: "127.0.0.1"},
646
+ )
647
+
648
+ assert response.get_status_code() == 200
649
+ assert (
650
+ await client.request("GET", f"http://{_PINNED_HOST}:{self.port}/")
651
+ ).get_status_code() == 200
652
+
653
+ with pytest.raises(TransportError):
654
+ _ = await client.request("GET", f"http://{_PINNED_HOST}:{self.port}/", timeout=1)
655
+
656
+ async def test_idn_resolve(self) -> None:
657
+ """A very long label and a non-ASCII name both resolve to what they are pinned to."""
658
+ client = self.create_client()
659
+
660
+ long_host = "0" + "-" * 61 + "0.com"
661
+ response = await client.request(
662
+ "GET",
663
+ f"http://{long_host}:{self.port}/",
664
+ resolve={long_host: "127.0.0.1"},
665
+ )
666
+
667
+ assert response.get_status_code() == 200
668
+
669
+ response = await client.request(
670
+ "GET",
671
+ f"http://Bücher.example:{self.port}/",
672
+ resolve={"xn--bcher-kva.example": "127.0.0.1"},
673
+ )
674
+
675
+ assert response.get_status_code() == 200
676
+
677
+ async def test_ipv6_resolve(self) -> None:
678
+ """A host pinned to an IPv6 address is reached over IPv6."""
679
+ server = TestHttpServer(host=_IPV6_HOST)
680
+ server.start()
681
+ try:
682
+ client = self.create_client()
683
+ response = await client.request(
684
+ "GET",
685
+ f"http://{_PINNED_HOST}:{server.port}/",
686
+ resolve={_PINNED_HOST: _IPV6_HOST},
687
+ )
688
+
689
+ assert response.get_status_code() == 200
690
+ finally:
691
+ server.stop()
692
+
693
+ async def test_not_a_timeout(self) -> None:
694
+ """The timeout is how long a transfer may go quiet for, not how long it may take."""
695
+ client = self.create_client()
696
+ response = await client.request("GET", f"{self.base_uri}/timeout-header", timeout=0.9)
697
+ await anyio.sleep(1)
698
+ assert response.get_status_code() == 200
699
+
700
+ async def test_timeout_on_access(self) -> None:
701
+ """A head that does not arrive in time fails the request."""
702
+ client = self.create_client()
703
+
704
+ with pytest.raises(TransportError):
705
+ _ = await client.request("GET", f"{self.base_uri}/timeout-header", timeout=0.1)
706
+
707
+ async def test_timeout_is_not_a_fatal_error(self) -> None:
708
+ """A transfer that timed out mid-body carries on being read."""
709
+ await anyio.sleep(0.3) # let the previous test release the server
710
+ client = self.create_client()
711
+ response = await client.request("GET", f"{self.base_uri}/timeout-body", timeout=0.25)
712
+
713
+ with pytest.raises(TransportTimeoutError):
714
+ _ = await response.get_content()
715
+
716
+ for _attempt in range(10):
717
+ try:
718
+ content = await response.get_content()
719
+ except TransportTimeoutError:
720
+ continue
721
+ assert content == b"<1><2>"
722
+ break
723
+ else:
724
+ pytest.fail("the body never arrived")
725
+
726
+ async def test_timeout_with_active_concurrent_stream(self) -> None:
727
+ """A transfer that keeps arriving does not hold a timeout back."""
728
+ first = TestHttpServer()
729
+ second = TestHttpServer()
730
+ first.start()
731
+ second.start()
732
+ try:
733
+ client = self.create_client()
734
+ streaming = await client.request("GET", f"http://{_HOST}:{first.port}/max-duration")
735
+ blocking = await client.request(
736
+ "GET",
737
+ f"http://{_HOST}:{second.port}/timeout-body",
738
+ timeout=0.25,
739
+ )
740
+
741
+ assert streaming.get_status_code() == 200
742
+ assert blocking.get_status_code() == 200
743
+
744
+ with pytest.raises(TransportError):
745
+ _ = await blocking.get_content()
746
+ finally:
747
+ first.stop()
748
+ second.stop()
749
+
750
+ async def test_timeout_on_initialize(self) -> None:
751
+ """Four heads that never arrive time out alongside each other, not one after another."""
752
+ first = TestHttpServer()
753
+ second = TestHttpServer()
754
+ first.start()
755
+ second.start()
756
+ try:
757
+ client = self.create_client()
758
+ start = time.monotonic()
759
+
760
+ async with anyio.create_task_group() as requests:
761
+ for port in (first.port, second.port, first.port, second.port):
762
+ _ = requests.start_soon(self._expect_timeout, client, port)
763
+
764
+ assert time.monotonic() - start < 1.0
765
+ finally:
766
+ first.stop()
767
+ second.stop()
768
+
769
+ async def test_timeout_on_destruct(self) -> None:
770
+ """The same four, each reported where its request was made."""
771
+ first = TestHttpServer()
772
+ second = TestHttpServer()
773
+ first.start()
774
+ second.start()
775
+ try:
776
+ client = self.create_client()
777
+ start = time.monotonic()
778
+
779
+ for port in (first.port, second.port, first.port, second.port):
780
+ with pytest.raises(TransportError):
781
+ _ = await client.request(
782
+ "GET",
783
+ f"http://{_HOST}:{port}/timeout-header",
784
+ timeout=0.25,
785
+ )
786
+
787
+ assert time.monotonic() - start < 2.0
788
+ finally:
789
+ first.stop()
790
+ second.stop()
791
+
792
+ async def test_destruct(self) -> None:
793
+ """Leaving the block frees a transfer rather than waiting for the rest of it."""
794
+ client = self.create_client()
795
+
796
+ start = time.monotonic()
797
+ async with await client.request("GET", f"{self.base_uri}/timeout-long"):
798
+ pass
799
+ duration = time.monotonic() - start
800
+
801
+ assert duration > 1
802
+ assert duration < 4
803
+
804
+ async def test_proxy(self) -> None:
805
+ """The ``proxy`` option is asked for the absolute URL, with its own credentials."""
806
+ client = self.create_client()
807
+ response = await client.request("GET", f"{self.base_uri}/", proxy=self.base_uri)
808
+
809
+ body = await response.to_array()
810
+ assert body["HTTP_HOST"] == f"{_HOST}:{self.port}"
811
+ assert self._is_proxied(body["REQUEST_URI"])
812
+
813
+ response = await client.request(
814
+ "GET",
815
+ f"{self.base_uri}/",
816
+ proxy=f"http://foo:b%3Dar@{_HOST}:{self.port}",
817
+ )
818
+
819
+ body = await response.to_array()
820
+ assert body["HTTP_PROXY_AUTHORIZATION"] == "Basic Zm9vOmI9YXI="
821
+
822
+ response = await client.request("GET", f"{self.base_uri}/301/proxy", proxy=self.base_uri)
823
+
824
+ body = await response.to_array()
825
+ assert body["HTTP_HOST"] == f"{_HOST}:{self.port}"
826
+ assert self._is_proxied(body["REQUEST_URI"])
827
+
828
+ async def test_no_proxy(self) -> None:
829
+ """``http_proxy`` is obeyed with no option at all, and ``no_proxy`` exempts a host."""
830
+ client = self.create_client()
831
+
832
+ with pytest.MonkeyPatch.context() as environment:
833
+ environment.setenv("http_proxy", self.base_uri)
834
+
835
+ body = await (await client.request("GET", f"{self.base_uri}/")).to_array()
836
+ assert self._is_proxied(body["REQUEST_URI"])
837
+
838
+ environment.setenv("no_proxy", _HOST)
839
+
840
+ body = await (await client.request("GET", f"{self.base_uri}/")).to_array()
841
+ assert body["REQUEST_URI"] == "/"
842
+
843
+ async def test_auto_encoding_request(self) -> None:
844
+ """A client asks for a compressed body by itself, and hands it over decoded."""
845
+ client = self.create_client()
846
+ response = await client.request("GET", self.base_uri)
847
+
848
+ assert response.get_status_code() == 200
849
+
850
+ assert _header(response, "vary") == ["Accept-Encoding"]
851
+ assert "gzip" in _header(response, "content-encoding")[0]
852
+
853
+ body = await response.to_array()
854
+
855
+ assert "gzip" in _expect(body["HTTP_ACCEPT_ENCODING"], str)
856
+
857
+ async def test_base_uri(self) -> None:
858
+ """A relative URL is resolved against the base, traversal included."""
859
+ client = self.create_client()
860
+ response = await client.request("GET", "../404", base_uri=f"{self.base_uri}/abc/")
861
+
862
+ assert response.get_status_code() == 404
863
+ assert _header(response, "content-type") == ["application/json"]
864
+
865
+ async def test_query(self) -> None:
866
+ """Query parameters are merged into the ones the URL already carried."""
867
+ client = self.create_client()
868
+ response = await client.request("GET", f"{self.base_uri}/?a=a", query={"b": "b"})
869
+
870
+ body = await response.to_array()
871
+ assert body["REQUEST_METHOD"] == "GET"
872
+ assert body["REQUEST_URI"] == "/?a=a&b=b"
873
+
874
+ async def test_informational_response(self) -> None:
875
+ """A 1xx ahead of the real status is read past, not answered with."""
876
+ client = self.create_client()
877
+ response = await client.request("GET", f"{self.base_uri}/103")
878
+
879
+ assert await response.get_content() == b"Here the body"
880
+ assert response.get_status_code() == 200
881
+
882
+ async def test_userland_encoding_request(self) -> None:
883
+ """Asking for an encoding outright hands the body over as it arrived."""
884
+ client = self.create_client()
885
+ response = await client.request("GET", self.base_uri, headers={"Accept-Encoding": "gzip"})
886
+
887
+ assert _header(response, "vary") == ["Accept-Encoding"]
888
+ assert "gzip" in _header(response, "content-encoding")[0]
889
+
890
+ content = await response.get_content()
891
+ assert content[:1] == b"\x1f"
892
+
893
+ body = _decode_object(gzip.decompress(content))
894
+ assert body["HTTP_ACCEPT_ENCODING"] == "gzip"
895
+
896
+ async def test_gzip_broken(self) -> None:
897
+ """A body compressed only in name fails the transfer."""
898
+ client = self.create_client()
899
+ response = await client.request("GET", f"{self.base_uri}/gzip-broken")
900
+
901
+ with pytest.raises(TransportError):
902
+ _ = await response.get_content()
903
+
904
+ async def test_max_duration(self) -> None:
905
+ """A transfer outlasting its whole-request limit ends there, however much arrives."""
906
+ client = self.create_client()
907
+ response = await client.request("GET", f"{self.base_uri}/max-duration", max_duration=0.1)
908
+
909
+ start = time.monotonic()
910
+
911
+ with pytest.raises(TransportError):
912
+ _ = await response.get_content()
913
+
914
+ assert time.monotonic() - start < 10
915
+
916
+ async def test_max_connect_duration(self) -> None:
917
+ """Connecting is given its own limit, well short of the idle one."""
918
+ client = self.create_client()
919
+
920
+ start = time.monotonic()
921
+
922
+ with pytest.raises(TransportError):
923
+ _ = await client.request(
924
+ "GET",
925
+ "http://10.255.255.1/",
926
+ max_connect_duration=0.1,
927
+ timeout=10,
928
+ )
929
+
930
+ duration = time.monotonic() - start
931
+
932
+ assert duration < 2, "should give up long before the 10s idle timeout"
933
+ assert duration > 0.05, "should take at least some time before giving up"
934
+
935
+ async def test_with_options(self) -> None:
936
+ """Options become the defaults of a new client, and leave this one as it was."""
937
+ client = self.create_client()
938
+ scoped = client.with_options(base_uri=f"{self.base_uri}/")
939
+
940
+ assert scoped is not client
941
+ assert type(scoped) is type(client)
942
+
943
+ response = await scoped.request("GET", "/")
944
+ assert response.get_status_code() == 200
945
+
946
+ async def test_send_request_prefers_the_request_headers(self) -> None:
947
+ """A header the request carries is sent; one it lacks is filled from the options."""
948
+ client = self.create_client()
949
+ request = Request("GET", f"{self.base_uri}/", headers={"Foo": "from-request"})
950
+
951
+ response = await client.send_request(
952
+ request,
953
+ headers={"Foo": "from-options", "Bar": "from-options"},
954
+ )
955
+
956
+ body = await response.to_array()
957
+ assert body["HTTP_FOO"] == "from-request"
958
+ assert body["HTTP_BAR"] == "from-options"
959
+
960
+ async def test_send_request_resolves_a_relative_request_uri(self) -> None:
961
+ """A request whose URI is relative is resolved against ``base_uri``."""
962
+ client = self.create_client()
963
+
964
+ response = await client.send_request(
965
+ Request("GET", "../404"),
966
+ base_uri=f"{self.base_uri}/abc/",
967
+ )
968
+
969
+ assert response.get_status_code() == 404
970
+
971
+ async def test_send_request_merges_the_query_option(self) -> None:
972
+ """``query`` is merged into the query the request's URI already carried."""
973
+ client = self.create_client()
974
+
975
+ response = await client.send_request(
976
+ Request("GET", f"{self.base_uri}/?a=a"),
977
+ query={"b": "b"},
978
+ )
979
+
980
+ body = await response.to_array()
981
+ assert body["REQUEST_URI"] == "/?a=a&b=b"
982
+
983
+ async def test_send_request_applies_auth_bearer_only_without_one(self) -> None:
984
+ """``auth_bearer`` fills an Authorization the request has none of, and replaces none."""
985
+ client = self.create_client()
986
+
987
+ response = await client.send_request(
988
+ Request("GET", f"{self.base_uri}/"),
989
+ auth_bearer="from-the-option",
990
+ )
991
+
992
+ assert (await response.to_array())["HTTP_AUTHORIZATION"] == "Bearer from-the-option"
993
+
994
+ response = await client.send_request(
995
+ Request(
996
+ "GET",
997
+ f"{self.base_uri}/",
998
+ headers={"Authorization": "Bearer from-the-request"},
999
+ ),
1000
+ auth_bearer="from-the-option",
1001
+ )
1002
+
1003
+ assert (await response.to_array())["HTTP_AUTHORIZATION"] == "Bearer from-the-request"
1004
+
1005
+ async def test_send_request_refuses_a_body_on_both(self) -> None:
1006
+ """A request carrying a body plus a ``body`` option is a request refused, not merged."""
1007
+ client = self.create_client()
1008
+ request = Request("POST", f"{self.base_uri}/post", body=b"foo=bar")
1009
+
1010
+ with pytest.raises(InvalidArgumentError, match=re.escape(_BOTH_BODIES)):
1011
+ _ = await client.send_request(request, body={"foo": "baz"})
1012
+
1013
+ async def test_request_is_send_request_of_the_same_request(self) -> None:
1014
+ """``request(method, url)`` answers as ``send_request`` of that method and URL does."""
1015
+ client = self.create_client()
1016
+
1017
+ direct = await (await client.request("GET", f"{self.base_uri}/")).to_array()
1018
+ sent = await (await client.send_request(Request("GET", f"{self.base_uri}/"))).to_array()
1019
+
1020
+ assert _without_client_port(sent) == _without_client_port(direct)
1021
+
1022
+ def _is_proxied(self, request_uri: object) -> bool:
1023
+ """Whether the server was asked for its own root in absolute form, as a proxy is."""
1024
+ proxied = rf"http://(localhost|127\.0\.0\.1):{self.port}/"
1025
+ return re.fullmatch(proxied, _expect(request_uri, str)) is not None
1026
+
1027
+ @staticmethod
1028
+ async def _expect_timeout(client: HttpClientInterface, port: int) -> None:
1029
+ """Make a request whose head never arrives, and expect it to say so."""
1030
+ with pytest.raises(TransportError):
1031
+ _ = await client.request(
1032
+ "GET",
1033
+ f"http://{_HOST}:{port}/timeout-header",
1034
+ timeout=0.25,
1035
+ )