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,69 @@
1
+ """What a request asks for when nobody asked for anything in particular."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Final
6
+
7
+ if TYPE_CHECKING:
8
+ from .request_options import RequestOptions
9
+
10
+ __all__ = ["OPTIONS_DEFAULTS"]
11
+
12
+ OPTIONS_DEFAULTS: Final[RequestOptions] = {
13
+ # No credentials are sent unless the caller asks for them.
14
+ "auth_basic": None,
15
+ "auth_bearer": None,
16
+ # Nothing is added to the URL, nothing is sent alongside it.
17
+ "query": {},
18
+ "headers": {},
19
+ "body": b"",
20
+ "json": None,
21
+ # Carried for the caller, never read.
22
+ "user_data": None,
23
+ # Enough hops for any sane chain, few enough to end a loop.
24
+ "max_redirects": 20,
25
+ # The best version the connection offers.
26
+ "http_version": None,
27
+ # Every URL is absolute until a caller says otherwise.
28
+ "base_uri": None,
29
+ # 60 seconds: long enough for a slow server, short enough to free the
30
+ # connection.
31
+ "timeout": None,
32
+ # Neither the transfer as a whole nor connecting is time limited: the
33
+ # idle timeout already ends a transfer nothing is coming in on.
34
+ "max_duration": 0.0,
35
+ "max_connect_duration": 0.0,
36
+ # Hosts are resolved the usual way, and reached without a proxy.
37
+ "resolve": {},
38
+ "proxy": None,
39
+ # A certificate is checked, and checked against the host asked for.
40
+ # Turning either off is a decision an application makes in the open.
41
+ "verify_peer": True,
42
+ "verify_host": True,
43
+ # The trust store and the client certificate are whatever the platform
44
+ # offers.
45
+ "cafile": None,
46
+ "capath": None,
47
+ "local_cert": None,
48
+ "local_pk": None,
49
+ "passphrase": None,
50
+ # How often a client that retries tries again.
51
+ "max_retries": 3,
52
+ # Additional options a client may ignore when it does not support them,
53
+ # unlike the regular options it must refuse; `use_persistent_connections`
54
+ # says whether to use persistent connections where supported.
55
+ "extra": {"use_persistent_connections": False},
56
+ }
57
+ """Every option the contract names, mapped to what it means to leave it out.
58
+
59
+ A client merges what a request was given over its own defaults, and those
60
+ over this, so every option always has a value by the time a request is made.
61
+ Nothing here is an instruction — it is the absence of one, which is why the
62
+ defaults name no host, no credentials and no deadline.
63
+
64
+ The entries worth reading twice: ``timeout`` of ``None`` is not "no timeout"
65
+ but 60 seconds; ``max_duration`` and ``max_connect_duration`` of zero *are*
66
+ "no limit".
67
+
68
+ The mapping is shared, so merge into a copy rather than writing to it.
69
+ """
File without changes
@@ -0,0 +1,106 @@
1
+ """Everything a request can be asked to do, as one typed mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, TypedDict
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import AsyncIterable, Mapping, Sequence
9
+
10
+ __all__ = ["RequestOptions"]
11
+
12
+
13
+ class RequestOptions(TypedDict, total=False):
14
+ """How a request is to be made, beyond its method and URL.
15
+
16
+ Every option is optional, and an option left out is one nobody asked
17
+ about: the client fills it in from its own defaults, which start at
18
+ :data:`OPTIONS_DEFAULTS
19
+ <xtr_http_client_contracts.options_defaults.OPTIONS_DEFAULTS>`. The same
20
+ keys are what :meth:`HttpClientInterface.with_options
21
+ <xtr_http_client_contracts.http_client_interface.HttpClientInterface.with_options>`
22
+ takes, where they become the defaults of the client it returns.
23
+
24
+ A name not listed here is refused. ``extra`` carries what one client
25
+ understands and another has no use for, so entries in it may be ignored.
26
+
27
+ Attributes:
28
+ auth_basic: Credentials for basic authentication: ``"user:password"``,
29
+ or the user alone, or the two as a tuple. ``None`` sends none.
30
+ auth_bearer: A token to send as a bearer credential. ``None`` sends
31
+ none.
32
+ query: Parameters to merge into the URL's query string. A scalar is one
33
+ value; a sequence repeats the name once per item. ``None``, booleans
34
+ and nested mappings are not accepted. They win over a parameter of
35
+ the same name already in the URL.
36
+ headers: Headers to send, a mapping of name to one value or several.
37
+ body: What to send as the request body. Bytes or text go as they are;
38
+ a mapping is sent as an HTML form; an async iterable of bytes is
39
+ sent as it is produced, which is how a body too large to hold is
40
+ uploaded.
41
+ json: A value to send as a JSON body. The client encodes it and sets
42
+ a JSON content type unless ``headers`` already names one. Setting
43
+ both this and ``body`` is refused.
44
+ user_data: Anything the caller wants to find again on the response,
45
+ under the ``user_data`` entry of ``get_info()``. Never read by
46
+ the client.
47
+ max_redirects: How many redirects to follow. Zero or less follows
48
+ none, which leaves the 3xx to be answered with.
49
+ http_version: The HTTP version to ask for — ``"1.0"``, ``"1.1"``,
50
+ ``"2"`` or ``"2.0"``. ``None`` leaves the client to use the best
51
+ version the connection offers.
52
+ base_uri: The URI a relative URL is resolved against. A sequence of
53
+ them is only for a client that retries, which moves along it from
54
+ one attempt to the next.
55
+ timeout: How long the transfer may go quiet for, in seconds. ``None``
56
+ is 60 seconds.
57
+ max_duration: How long the whole request and response may take, in
58
+ seconds. Zero is unlimited.
59
+ max_connect_duration: How long resolving, connecting and the TLS
60
+ handshake may take, in seconds. Zero is unlimited.
61
+ resolve: Host to IP address, used instead of asking a resolver. This
62
+ is what pins a request to an address that has already been
63
+ checked, so that nothing can change under it between the check
64
+ and the connection.
65
+ proxy: The proxy to go through, as a URL.
66
+ verify_peer: Whether to check that the peer's certificate is trusted.
67
+ verify_host: Whether to check that the certificate is for the host
68
+ being asked for.
69
+ cafile: A file of certificates to trust instead of the defaults.
70
+ capath: A directory of certificates to trust instead of the defaults.
71
+ local_cert: A client certificate to present.
72
+ local_pk: The private key of ``local_cert``, when it is kept apart.
73
+ passphrase: The passphrase of ``local_pk``.
74
+ max_retries: How many times a client that retries may try the request
75
+ again. A client that does not retry ignores it.
76
+ extra: Additional options a client may ignore when it does not
77
+ support them, unlike the regular options it must refuse:
78
+ ``use_persistent_connections`` says whether to use persistent
79
+ connections where supported, ``trace_content`` whether a tracing
80
+ client keeps the bodies.
81
+ """
82
+
83
+ auth_basic: str | tuple[str] | tuple[str, str] | None
84
+ auth_bearer: str | None
85
+ query: Mapping[str, str | int | float | Sequence[str | int | float]]
86
+ headers: Mapping[str, str | Sequence[str]]
87
+ body: bytes | str | Mapping[str, object] | AsyncIterable[bytes]
88
+ json: object
89
+ user_data: object
90
+ max_redirects: int
91
+ http_version: str | None
92
+ base_uri: str | Sequence[str] | None
93
+ timeout: float | None
94
+ max_duration: float
95
+ max_connect_duration: float
96
+ resolve: Mapping[str, str]
97
+ proxy: str | None
98
+ verify_peer: bool
99
+ verify_host: bool
100
+ cafile: str | None
101
+ capath: str | None
102
+ local_cert: str | None
103
+ local_pk: str | None
104
+ passphrase: str | None
105
+ max_retries: int
106
+ extra: Mapping[str, object]
@@ -0,0 +1,12 @@
1
+ """What an implementation of the client contract tests itself with.
2
+
3
+ Install it with the ``testing`` extra. Nothing here is imported by the contract itself, so an
4
+ application that only depends on the interfaces never pays for it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from .http_client_test_case import HttpClientTestCase
10
+ from .test_http_server import TestHttpServer
11
+
12
+ __all__ = ["HttpClientTestCase", "TestHttpServer"]
@@ -0,0 +1,184 @@
1
+ """One request the fixture server read, and the variables its echo routes answer with.
2
+
3
+ The echo routes answer a JSON object describing the request they received, so a client test can
4
+ assert on what actually arrived. The names in that object are:
5
+
6
+ ====================== ====================================================================
7
+ ``SERVER_PROTOCOL`` the protocol token the request came in with, e.g. ``HTTP/1.1``
8
+ ``SERVER_NAME`` the host the server is bound to, not the one the request asked for
9
+ ``REQUEST_URI`` the raw request target, absolute-form and query string included
10
+ ``REQUEST_METHOD`` the method, as sent
11
+ ``AUTH_USER`` the user of the request's Basic credentials, when it carried any
12
+ ``AUTH_PW`` their password, split off at the first colon
13
+ ``REMOTE_ADDR`` the address the connection came from
14
+ ``REMOTE_PORT`` its port, as a string
15
+ ``HTTP_<NAME>`` every request header, upper-cased, dashes turned into underscores
16
+ ====================== ====================================================================
17
+
18
+ The order is the one above: the fixed names first, then the headers as they arrived. A header
19
+ sent more than once is answered once, its values joined with ``", "``. ``AUTH_USER`` and
20
+ ``AUTH_PW`` appear only when the request carried credentials this module could decode — the
21
+ fixture these routes answer like exposed the same two values under names belonging to its own
22
+ runtime, which would mean nothing here.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from base64 import b64decode
29
+ from dataclasses import dataclass
30
+ from typing import TYPE_CHECKING, final
31
+ from urllib.parse import urlsplit
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Sequence
35
+
36
+ __all__ = ["FixtureRequest", "read_request"]
37
+
38
+ _REQUEST_LINE_PARTS = 3
39
+ _BASIC = "basic"
40
+
41
+
42
+ @final
43
+ @dataclass(frozen=True, slots=True)
44
+ class FixtureRequest:
45
+ """What arrived on one connection, parsed no further than the routes need."""
46
+
47
+ method: str
48
+ target: str
49
+ protocol: str
50
+ headers: tuple[tuple[str, str], ...]
51
+ body: bytes
52
+ peer: tuple[str, str]
53
+ server_name: str
54
+ server_port: int
55
+
56
+ @property
57
+ def path(self) -> str:
58
+ """The target's path, which is what the routes dispatch on.
59
+
60
+ An absolute target — what a client sends through a proxy — routes on its path too, so
61
+ ``http://localhost:<port>/301/proxy`` reaches the same route as ``/301/proxy``.
62
+ """
63
+ return urlsplit(self.target).path
64
+
65
+ @property
66
+ def query(self) -> str:
67
+ """The target's query string, undecoded."""
68
+ return urlsplit(self.target).query
69
+
70
+ def header(self, name: str) -> str | None:
71
+ """The named header, repeats joined, or ``None`` when it was not sent."""
72
+ return _joined(self.headers, name)
73
+
74
+ def credentials(self) -> tuple[str, str] | None:
75
+ """The Basic user and password the request carried, or ``None``.
76
+
77
+ Anything that is not a decodable ``Basic`` token holding a colon counts as no
78
+ credentials at all, which is how a client sending a bearer token reaches a route that
79
+ only looks for Basic.
80
+ """
81
+ authorization = self.header("authorization")
82
+ if authorization is None:
83
+ return None
84
+ scheme, _, token = authorization.partition(" ")
85
+ if scheme.lower() != _BASIC:
86
+ return None
87
+ try:
88
+ decoded = b64decode(token, validate=True).decode()
89
+ except ValueError:
90
+ return None
91
+ user, separator, password = decoded.partition(":")
92
+ return (user, password) if separator else None
93
+
94
+ def variables(self) -> dict[str, str]:
95
+ """The object the echo routes answer with, in the order described for this module."""
96
+ variables = {
97
+ "SERVER_PROTOCOL": self.protocol,
98
+ "SERVER_NAME": self.server_name,
99
+ "REQUEST_URI": self.target,
100
+ "REQUEST_METHOD": self.method,
101
+ }
102
+ credentials = self.credentials()
103
+ if credentials is not None:
104
+ variables["AUTH_USER"], variables["AUTH_PW"] = credentials
105
+ variables["REMOTE_ADDR"] = self.peer[0]
106
+ variables["REMOTE_PORT"] = self.peer[1]
107
+ for name, _ in self.headers:
108
+ key = "HTTP_" + name.upper().replace("-", "_")
109
+ variables[key] = _joined(self.headers, name) or ""
110
+ return variables
111
+
112
+
113
+ async def read_request(
114
+ reader: asyncio.StreamReader,
115
+ writer: asyncio.StreamWriter,
116
+ server_name: str,
117
+ ) -> FixtureRequest | None:
118
+ """Read one request, or answer ``None`` for anything that is not one.
119
+
120
+ A connection that says nothing, or opens with something that is not a request line, gets
121
+ no answer at all: a client probing the port should see it closed, not a protocol error.
122
+ """
123
+ try:
124
+ head = await reader.readuntil(b"\r\n\r\n")
125
+ except (asyncio.IncompleteReadError, asyncio.LimitOverrunError):
126
+ return None
127
+ lines = head.decode("latin-1").split("\r\n")
128
+ parts = lines[0].split(" ")
129
+ if len(parts) != _REQUEST_LINE_PARTS or not parts[2].startswith("HTTP/"):
130
+ return None
131
+ method, target, protocol = parts
132
+ headers = tuple(
133
+ (name.strip(), value.strip())
134
+ for name, separator, value in (line.partition(":") for line in lines[1:] if line)
135
+ if separator
136
+ )
137
+ return FixtureRequest(
138
+ method=method,
139
+ target=target,
140
+ protocol=protocol,
141
+ headers=headers,
142
+ body=await _read_body(reader, headers),
143
+ peer=_peer(writer),
144
+ server_name=server_name,
145
+ server_port=_local_port(writer),
146
+ )
147
+
148
+
149
+ def _joined(headers: Sequence[tuple[str, str]], name: str) -> str | None:
150
+ wanted = name.lower()
151
+ values = [value for key, value in headers if key.lower() == wanted]
152
+ return ", ".join(values) if values else None
153
+
154
+
155
+ def _peer(writer: asyncio.StreamWriter) -> tuple[str, str]:
156
+ """The address and port the connection came from, as text, which is how they are echoed."""
157
+ address: tuple[object, ...] = writer.get_extra_info("peername")
158
+ return str(address[0]), str(address[1])
159
+
160
+
161
+ def _local_port(writer: asyncio.StreamWriter) -> int:
162
+ """The port the connection reached, which a redirect back to this server must name."""
163
+ address: tuple[object, ...] = writer.get_extra_info("sockname")
164
+ return int(str(address[1]))
165
+
166
+
167
+ async def _read_body(reader: asyncio.StreamReader, headers: Sequence[tuple[str, str]]) -> bytes:
168
+ transfer_encoding = _joined(headers, "transfer-encoding") or ""
169
+ if "chunked" in transfer_encoding.lower():
170
+ return await _read_chunks(reader)
171
+ declared = _joined(headers, "content-length")
172
+ length = int(declared) if declared is not None and declared.isdigit() else 0
173
+ return await reader.readexactly(length) if length else b""
174
+
175
+
176
+ async def _read_chunks(reader: asyncio.StreamReader) -> bytes:
177
+ body = bytearray()
178
+ while True:
179
+ size = int((await reader.readline()).strip() or b"0", 16)
180
+ if size == 0:
181
+ _ = await reader.readline()
182
+ return bytes(body)
183
+ body += await reader.readexactly(size)
184
+ _ = await reader.readline()
@@ -0,0 +1,80 @@
1
+ """How the fixture server puts a response on the wire.
2
+
3
+ Responses are assembled as bytes rather than by a server framework: several routes answer
4
+ something deliberately malformed — a length that does not match the body, a declared encoding
5
+ the body does not use, framing that stops mid-chunk — and no framework would emit those.
6
+
7
+ Every response answers in the protocol its request came in with and closes the connection, so
8
+ a client never has to guess where a body that declares no length ends.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import gzip
14
+ import json
15
+ from dataclasses import dataclass
16
+ from http import HTTPStatus
17
+ from typing import TYPE_CHECKING, final
18
+
19
+ if TYPE_CHECKING:
20
+ from asyncio import StreamWriter
21
+ from collections.abc import Mapping
22
+
23
+ from ._fixture_request import FixtureRequest
24
+
25
+ __all__ = ["Answer", "compact", "compressed", "head", "length", "pretty", "send"]
26
+
27
+ _PHRASES = {status.value: status.phrase for status in HTTPStatus}
28
+
29
+
30
+ @final
31
+ @dataclass(frozen=True, slots=True)
32
+ class Answer:
33
+ """A status and the headers to answer with; the body follows them."""
34
+
35
+ status: int
36
+ headers: tuple[tuple[str, str], ...] = ()
37
+
38
+
39
+ def head(request: FixtureRequest, answer: Answer) -> bytes:
40
+ """The status line and headers, ending with the blank line that opens the body."""
41
+ phrase = _PHRASES.get(answer.status, "Unknown")
42
+ lines = [f"{request.protocol} {answer.status} {phrase}"]
43
+ lines += [f"{name}: {value}" for name, value in answer.headers]
44
+ lines.append("Connection: close")
45
+ return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")
46
+
47
+
48
+ async def send(writer: StreamWriter, *parts: bytes) -> None:
49
+ """Write, and wait for it to leave: a client that hung up ends the route here."""
50
+ writer.write(b"".join(parts))
51
+ await writer.drain()
52
+
53
+
54
+ def pretty(payload: Mapping[str, object]) -> bytes:
55
+ """JSON indented as the echo routes answer it."""
56
+ return json.dumps(payload, indent=4, ensure_ascii=False).encode()
57
+
58
+
59
+ def compact(payload: Mapping[str, object]) -> str:
60
+ """JSON with no spacing, for the payloads that travel in a header."""
61
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
62
+
63
+
64
+ def length(body: bytes) -> tuple[str, str]:
65
+ """The header declaring ``body``'s real length."""
66
+ return ("Content-Length", str(len(body)))
67
+
68
+
69
+ def compressed(
70
+ request: FixtureRequest,
71
+ payload: bytes,
72
+ ) -> tuple[bytes, tuple[tuple[str, str], ...]]:
73
+ """``payload`` gzipped and the headers saying so, when the request asked for gzip.
74
+
75
+ A request that did not ask gets the payload as it is, and no headers: a client must be
76
+ able to drive the same route both ways.
77
+ """
78
+ if "gzip" not in (request.header("accept-encoding") or ""):
79
+ return payload, ()
80
+ return gzip.compress(payload), (("Content-Encoding", "gzip"), ("Vary", "Accept-Encoding"))