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.
- xtr_http_client_contracts/__init__.py +47 -0
- xtr_http_client_contracts/exception/__init__.py +40 -0
- xtr_http_client_contracts/exception/client_error.py +16 -0
- xtr_http_client_contracts/exception/decoding_error.py +32 -0
- xtr_http_client_contracts/exception/http_client_error.py +21 -0
- xtr_http_client_contracts/exception/http_error.py +51 -0
- xtr_http_client_contracts/exception/invalid_argument_error.py +27 -0
- xtr_http_client_contracts/exception/json_error.py +19 -0
- xtr_http_client_contracts/exception/redirection_error.py +21 -0
- xtr_http_client_contracts/exception/server_error.py +15 -0
- xtr_http_client_contracts/exception/transport_error.py +31 -0
- xtr_http_client_contracts/exception/transport_timeout_error.py +27 -0
- xtr_http_client_contracts/http_client_interface.py +124 -0
- xtr_http_client_contracts/http_response_interface.py +148 -0
- xtr_http_client_contracts/options_defaults.py +69 -0
- xtr_http_client_contracts/py.typed +0 -0
- xtr_http_client_contracts/request_options.py +106 -0
- xtr_http_client_contracts/testing/__init__.py +12 -0
- xtr_http_client_contracts/testing/_fixture_request.py +184 -0
- xtr_http_client_contracts/testing/_fixture_response.py +80 -0
- xtr_http_client_contracts/testing/_fixture_routes.py +321 -0
- xtr_http_client_contracts/testing/http_client_test_case.py +1035 -0
- xtr_http_client_contracts/testing/test_http_server.py +213 -0
- xtr_http_client_contracts-4.0.2.dist-info/METADATA +271 -0
- xtr_http_client_contracts-4.0.2.dist-info/RECORD +27 -0
- xtr_http_client_contracts-4.0.2.dist-info/WHEEL +4 -0
- xtr_http_client_contracts-4.0.2.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The async HTTP client contract: what code that calls out depends on, and nothing more.
|
|
2
|
+
|
|
3
|
+
A library that sends an HTTP request should not decide what sends it. It takes the client
|
|
4
|
+
interface, asks for a method and a URL — or hands over a message request — and gets back a
|
|
5
|
+
response whose status line and headers have arrived, its body read when, and only when, the
|
|
6
|
+
caller wants it.
|
|
7
|
+
|
|
8
|
+
What is here is only what appears in that contract: the client, the response it answers with,
|
|
9
|
+
the options a request takes, the errors any of them raises, and a conformance suite an
|
|
10
|
+
implementation can run against itself.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .exception import (
|
|
16
|
+
ClientError,
|
|
17
|
+
DecodingError,
|
|
18
|
+
HttpClientError,
|
|
19
|
+
HttpError,
|
|
20
|
+
InvalidArgumentError,
|
|
21
|
+
JsonError,
|
|
22
|
+
RedirectionError,
|
|
23
|
+
ServerError,
|
|
24
|
+
TransportError,
|
|
25
|
+
TransportTimeoutError,
|
|
26
|
+
)
|
|
27
|
+
from .http_client_interface import HttpClientInterface
|
|
28
|
+
from .http_response_interface import HttpResponseInterface
|
|
29
|
+
from .options_defaults import OPTIONS_DEFAULTS
|
|
30
|
+
from .request_options import RequestOptions
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"OPTIONS_DEFAULTS",
|
|
34
|
+
"ClientError",
|
|
35
|
+
"DecodingError",
|
|
36
|
+
"HttpClientError",
|
|
37
|
+
"HttpClientInterface",
|
|
38
|
+
"HttpError",
|
|
39
|
+
"HttpResponseInterface",
|
|
40
|
+
"InvalidArgumentError",
|
|
41
|
+
"JsonError",
|
|
42
|
+
"RedirectionError",
|
|
43
|
+
"RequestOptions",
|
|
44
|
+
"ServerError",
|
|
45
|
+
"TransportError",
|
|
46
|
+
"TransportTimeoutError",
|
|
47
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The errors the HTTP client contract names.
|
|
2
|
+
|
|
3
|
+
:class:`HttpClientError` is the root every one of them derives from,
|
|
4
|
+
including every error an implementation adds, so catching it catches anything
|
|
5
|
+
calling out can go wrong with, whichever client raised it.
|
|
6
|
+
|
|
7
|
+
Below it, two branches that mean different things. A
|
|
8
|
+
:class:`TransportError` — with :class:`TransportTimeoutError` for running out
|
|
9
|
+
of time — says there is no answer to look at. An :class:`HttpError` says
|
|
10
|
+
there is one and it is not a success: :class:`RedirectionError` for a 3xx
|
|
11
|
+
nobody followed, :class:`ClientError` for a 4xx, :class:`ServerError` for a
|
|
12
|
+
5xx, each carrying the response. :class:`DecodingError` is the third case:
|
|
13
|
+
the answer arrived whole and is not what it was being read as.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .client_error import ClientError
|
|
19
|
+
from .decoding_error import DecodingError
|
|
20
|
+
from .http_client_error import HttpClientError
|
|
21
|
+
from .http_error import HttpError
|
|
22
|
+
from .invalid_argument_error import InvalidArgumentError
|
|
23
|
+
from .json_error import JsonError
|
|
24
|
+
from .redirection_error import RedirectionError
|
|
25
|
+
from .server_error import ServerError
|
|
26
|
+
from .transport_error import TransportError
|
|
27
|
+
from .transport_timeout_error import TransportTimeoutError
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ClientError",
|
|
31
|
+
"DecodingError",
|
|
32
|
+
"HttpClientError",
|
|
33
|
+
"HttpError",
|
|
34
|
+
"InvalidArgumentError",
|
|
35
|
+
"JsonError",
|
|
36
|
+
"RedirectionError",
|
|
37
|
+
"ServerError",
|
|
38
|
+
"TransportError",
|
|
39
|
+
"TransportTimeoutError",
|
|
40
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The request was refused: a 4xx came back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_error import HttpError
|
|
6
|
+
|
|
7
|
+
__all__ = ["ClientError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClientError(HttpError):
|
|
11
|
+
"""A 4xx status: the server understood the request and would not serve it.
|
|
12
|
+
|
|
13
|
+
Retrying the same request unchanged gets the same answer, so this is the
|
|
14
|
+
one range worth telling apart — a 401 asks for credentials, a 404 says
|
|
15
|
+
there is nothing there, a 429 says to slow down.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""The body arrived whole but is not what it was being read as."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_client_error import HttpClientError
|
|
6
|
+
|
|
7
|
+
__all__ = ["DecodingError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DecodingError(HttpClientError, ValueError):
|
|
11
|
+
"""A body could not be turned into the shape that was asked for.
|
|
12
|
+
|
|
13
|
+
What :meth:`HttpResponseInterface.to_array
|
|
14
|
+
<xtr_http_client_contracts.http_response_interface.HttpResponseInterface.to_array>`
|
|
15
|
+
raises when the body is empty, is not JSON, or is JSON that is not an
|
|
16
|
+
object. The transfer succeeded, so this is about the payload, not the
|
|
17
|
+
connection.
|
|
18
|
+
|
|
19
|
+
Also a :class:`ValueError`, so code that already guards what it parses
|
|
20
|
+
with ``except ValueError`` keeps working without learning a new
|
|
21
|
+
exception.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
reason: What is wrong with the body, naming the URL it came from.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
reason: str
|
|
28
|
+
|
|
29
|
+
def __init__(self, reason: str) -> None:
|
|
30
|
+
"""Record what is wrong with the body."""
|
|
31
|
+
self.reason = reason
|
|
32
|
+
super().__init__(reason)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The root every error of the HTTP client contract derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["HttpClientError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HttpClientError(Exception):
|
|
9
|
+
"""Base class for every error raised through the HTTP client contract.
|
|
10
|
+
|
|
11
|
+
Catch this to handle anything calling out can go wrong with, whichever
|
|
12
|
+
client is behind the interface; catch a subclass to handle one cause.
|
|
13
|
+
Every subclass carries what a caller needs as typed attributes and
|
|
14
|
+
composes its message from them.
|
|
15
|
+
|
|
16
|
+
A server answering is not a cause: a request that came back with a 404 or
|
|
17
|
+
a 503 succeeded as a transfer, so ``request()`` returns the response and
|
|
18
|
+
nothing is raised until someone reads its content and asks to be told.
|
|
19
|
+
What raises on its own is the transfer failing, a body that will not
|
|
20
|
+
decode, and an option no client here understands.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""A server answered, and the answer was not a success."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .http_client_error import HttpClientError
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from xtr_http_client_contracts.http_response_interface import HttpResponseInterface
|
|
11
|
+
|
|
12
|
+
__all__ = ["HttpError"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HttpError(HttpClientError):
|
|
16
|
+
"""The status code says the request did not get what it asked for.
|
|
17
|
+
|
|
18
|
+
Never raised by making the request: a status is an answer, not a failure
|
|
19
|
+
of the transfer, so it reaches the caller as a response. This is raised
|
|
20
|
+
where the caller asks to be told about it —
|
|
21
|
+
:meth:`HttpResponseInterface.get_content
|
|
22
|
+
<xtr_http_client_contracts.http_response_interface.HttpResponseInterface.get_content>`
|
|
23
|
+
or :meth:`HttpResponseInterface.to_array
|
|
24
|
+
<xtr_http_client_contracts.http_response_interface.HttpResponseInterface.to_array>`
|
|
25
|
+
with ``throw``.
|
|
26
|
+
|
|
27
|
+
Catch this to handle any unsuccessful status;
|
|
28
|
+
:class:`RedirectionError <...redirection_error.RedirectionError>`,
|
|
29
|
+
:class:`ClientError <...client_error.ClientError>` and
|
|
30
|
+
:class:`ServerError <...server_error.ServerError>` are the three ranges,
|
|
31
|
+
and they are siblings, so catching one leaves the others alone.
|
|
32
|
+
|
|
33
|
+
The response comes along because it is the only place the answer is left:
|
|
34
|
+
the status code, the headers and the body, which is kept.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
response: The response that was answered with.
|
|
38
|
+
reason: What the status means for this request, naming the code and
|
|
39
|
+
the URL. The message is this, composed by whoever raised it —
|
|
40
|
+
the body of an error document is the most useful thing to put
|
|
41
|
+
there and reading it is awaited, which a constructor cannot do.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
response: HttpResponseInterface
|
|
45
|
+
reason: str
|
|
46
|
+
|
|
47
|
+
def __init__(self, response: HttpResponseInterface, reason: str) -> None:
|
|
48
|
+
"""Record the response the status came on, and what it means."""
|
|
49
|
+
self.response = response
|
|
50
|
+
self.reason = reason
|
|
51
|
+
super().__init__(reason)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A method, URL or option the client cannot use; raised before anything is sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .transport_error import TransportError
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidArgumentError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Both bases reach `BaseException`, which basedpyright flags as unsafe multiple inheritance.
|
|
11
|
+
# It is safe here: `ValueError` adds no state, so `TransportError.__init__` is the only one
|
|
12
|
+
# construction runs.
|
|
13
|
+
class InvalidArgumentError( # pyright: ignore[reportUnsafeMultipleInheritance]
|
|
14
|
+
TransportError,
|
|
15
|
+
ValueError,
|
|
16
|
+
):
|
|
17
|
+
"""The request was refused before it was sent, for something it asked for.
|
|
18
|
+
|
|
19
|
+
An unknown option name, an option whose value the client cannot honour, a
|
|
20
|
+
method or URL it cannot make a request with, or a body given both with the
|
|
21
|
+
request and as an option. Nothing reached the network, so there is no
|
|
22
|
+
response, and the same call with a corrected argument would go through.
|
|
23
|
+
|
|
24
|
+
Also a :class:`ValueError`, so code that already guards the arguments it
|
|
25
|
+
passes with ``except ValueError`` keeps working without learning a new
|
|
26
|
+
exception.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""A body that is not the JSON object to_array() promises."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .decoding_error import DecodingError
|
|
6
|
+
|
|
7
|
+
__all__ = ["JsonError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class JsonError(DecodingError):
|
|
11
|
+
"""The body is JSON, but not the object :meth:`HttpResponseInterface.to_array` promises.
|
|
12
|
+
|
|
13
|
+
A JSON array, string, number or ``null`` where an object was asked for. The
|
|
14
|
+
body was read in full and parsed, so the failure is the shape of the payload,
|
|
15
|
+
not the transfer.
|
|
16
|
+
|
|
17
|
+
Also a :class:`DecodingError`, so code that already catches a body it could
|
|
18
|
+
not decode keeps catching this one.
|
|
19
|
+
"""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The answer points somewhere else, and nobody is following it any further."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_error import HttpError
|
|
6
|
+
|
|
7
|
+
__all__ = ["RedirectionError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedirectionError(HttpError):
|
|
11
|
+
"""A 3xx status that was not followed.
|
|
12
|
+
|
|
13
|
+
Raised once ``max_redirects`` has been reached, or straight away when
|
|
14
|
+
following was turned off with ``max_redirects`` of zero or less — a
|
|
15
|
+
redirect that *is* followed never reaches the caller, since what comes
|
|
16
|
+
back is the response from the last hop.
|
|
17
|
+
|
|
18
|
+
Where the answer points is in the ``redirect_url`` entry of
|
|
19
|
+
:meth:`HttpResponseInterface.get_info
|
|
20
|
+
<xtr_http_client_contracts.http_response_interface.HttpResponseInterface.get_info>`.
|
|
21
|
+
"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The server failed to answer the request: a 5xx came back."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_error import HttpError
|
|
6
|
+
|
|
7
|
+
__all__ = ["ServerError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServerError(HttpError):
|
|
11
|
+
"""A 5xx status: the request may have been fine, the server was not.
|
|
12
|
+
|
|
13
|
+
The far side is at fault, so the same request may well succeed later.
|
|
14
|
+
Nothing here retries; what to do about it is the caller's to decide.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The transfer itself failed, so there is no response to look at."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .http_client_error import HttpClientError
|
|
6
|
+
|
|
7
|
+
__all__ = ["TransportError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TransportError(HttpClientError):
|
|
11
|
+
"""Nothing usable came back, or the request could never be made.
|
|
12
|
+
|
|
13
|
+
A host that will not resolve, a connection refused or reset, a body that
|
|
14
|
+
stopped half way, a certificate the client would not accept — and the one
|
|
15
|
+
case that never touches the network: an option the client does not
|
|
16
|
+
support. A client reports that by raising rather than by ignoring it, so
|
|
17
|
+
an option that was spelled wrong, or that this client cannot honour,
|
|
18
|
+
cannot pass for one it quietly obeyed.
|
|
19
|
+
|
|
20
|
+
It is also what reading a response raises once it has been cancelled.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
reason: What went wrong, naming the URL it went wrong for.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
reason: str
|
|
27
|
+
|
|
28
|
+
def __init__(self, reason: str) -> None:
|
|
29
|
+
"""Record what went wrong."""
|
|
30
|
+
self.reason = reason
|
|
31
|
+
super().__init__(reason)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Nothing arrived in time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .transport_error import TransportError
|
|
6
|
+
|
|
7
|
+
__all__ = ["TransportTimeoutError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Both bases reach `BaseException`, which basedpyright flags as unsafe multiple inheritance.
|
|
11
|
+
# It is safe here: `TimeoutError` adds no state, so `TransportError.__init__` is the only one
|
|
12
|
+
# construction runs.
|
|
13
|
+
class TransportTimeoutError( # pyright: ignore[reportUnsafeMultipleInheritance]
|
|
14
|
+
TransportError,
|
|
15
|
+
TimeoutError,
|
|
16
|
+
):
|
|
17
|
+
"""The transfer went quiet for longer than it was allowed to.
|
|
18
|
+
|
|
19
|
+
Raised when the idle timeout passes with nothing coming in, when the
|
|
20
|
+
whole transfer outlasts ``max_duration``, or when connecting outlasts
|
|
21
|
+
``max_connect_duration``.
|
|
22
|
+
|
|
23
|
+
Also a :class:`TimeoutError`, which is what every async library in the
|
|
24
|
+
standard library raises and what ``anyio`` cancel scopes turn a deadline
|
|
25
|
+
into, so code that already gives up on a timeout keeps working without
|
|
26
|
+
learning a new exception.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The calls code that sends an HTTP request depends on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Unpack
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from xtr_http_message_contracts import RequestInterface
|
|
11
|
+
|
|
12
|
+
from .http_response_interface import HttpResponseInterface
|
|
13
|
+
from .request_options import RequestOptions
|
|
14
|
+
|
|
15
|
+
__all__ = ["HttpClientInterface"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class HttpClientInterface(Protocol):
|
|
20
|
+
"""Sends HTTP requests and hands back responses.
|
|
21
|
+
|
|
22
|
+
A library that calls out takes this interface, and the application
|
|
23
|
+
decides what is behind it — a real transfer, a scoped client, a guard, or
|
|
24
|
+
a client answering from memory in a test.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
response = await client.request("GET", "https://example.com/orders")
|
|
28
|
+
orders = await response.to_array()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Several requests run at once by being made from tasks of one task group.
|
|
32
|
+
|
|
33
|
+
An option name the contract does not list is refused with
|
|
34
|
+
:class:`InvalidArgumentError
|
|
35
|
+
<xtr_http_client_contracts.exception.invalid_argument_error.InvalidArgumentError>`:
|
|
36
|
+
``Unsupported option "foo" passed to "<client class>", did you mean "..."?``.
|
|
37
|
+
An option that was spelled wrong cannot pass for one that was obeyed.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
async def request(
|
|
41
|
+
self,
|
|
42
|
+
method: str,
|
|
43
|
+
url: str,
|
|
44
|
+
**options: Unpack[RequestOptions],
|
|
45
|
+
) -> HttpResponseInterface:
|
|
46
|
+
"""Make a request, and return once the status line and the headers are in.
|
|
47
|
+
|
|
48
|
+
The same as :meth:`send_request` with a message request carrying
|
|
49
|
+
``method`` and ``url``, no headers and an empty body.
|
|
50
|
+
|
|
51
|
+
It never raises because of the status: a 404 or a 503 is an answer,
|
|
52
|
+
and comes back as a response.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
method: The HTTP method, in upper case.
|
|
56
|
+
url: The URL to request, absolute or relative to the
|
|
57
|
+
``base_uri`` option.
|
|
58
|
+
**options: How the request is to be made; anything left out
|
|
59
|
+
falls back to this client's defaults.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The response, with its status and headers already in.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TransportTimeoutError: When no answer came in time.
|
|
66
|
+
InvalidArgumentError: For an invalid method, URL or option, before
|
|
67
|
+
anything is sent.
|
|
68
|
+
TransportError: When no answer came.
|
|
69
|
+
"""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
async def send_request(
|
|
73
|
+
self,
|
|
74
|
+
request: RequestInterface,
|
|
75
|
+
**options: Unpack[RequestOptions],
|
|
76
|
+
) -> HttpResponseInterface:
|
|
77
|
+
"""Send a message request, the options filling what it leaves out.
|
|
78
|
+
|
|
79
|
+
The request is authoritative:
|
|
80
|
+
|
|
81
|
+
- a relative URI is resolved against ``base_uri``;
|
|
82
|
+
- ``query`` is merged into the URI;
|
|
83
|
+
- a header from ``headers`` is sent only when the request has none of
|
|
84
|
+
that name;
|
|
85
|
+
- ``auth_basic`` and ``auth_bearer`` apply only when the request has
|
|
86
|
+
no ``Authorization`` header;
|
|
87
|
+
- ``body`` or ``json`` with a request whose body is not empty is
|
|
88
|
+
refused: ``Define either the request body or the "body" or "json"
|
|
89
|
+
option, setting both is not supported.``.
|
|
90
|
+
|
|
91
|
+
It returns, and never raises because of the status, as
|
|
92
|
+
:meth:`request` does.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
request: The request to send.
|
|
96
|
+
**options: What the request leaves out; anything left out of
|
|
97
|
+
both falls back to this client's defaults.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
The response, with its status and headers already in.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
TransportTimeoutError: When no answer came in time.
|
|
104
|
+
InvalidArgumentError: For an invalid method, URL or option, and for
|
|
105
|
+
a body given both with the request and as an option, before
|
|
106
|
+
anything is sent.
|
|
107
|
+
TransportError: When no answer came.
|
|
108
|
+
"""
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
def with_options(self, **options: Unpack[RequestOptions]) -> Self:
|
|
112
|
+
"""Return a client whose requests start from these options.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
**options: The defaults of the client returned, merged over this
|
|
116
|
+
client's own, so what is left out is kept.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A new client; this one is left as it was.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
InvalidArgumentError: When an option is invalid, as in :meth:`request`.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""A response that has started arriving, and what a client adds to it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from xtr_http_message_contracts import ResponseInterface
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from types import TracebackType
|
|
11
|
+
|
|
12
|
+
__all__ = ["HttpResponseInterface"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class HttpResponseInterface(ResponseInterface, Protocol):
|
|
17
|
+
"""An HTTP response whose body is read off the network when it is asked for.
|
|
18
|
+
|
|
19
|
+
It is a message response first: the status code and reason phrase, the
|
|
20
|
+
protocol version, the headers, the body as a stream and the ``with_*``
|
|
21
|
+
copies are the message contract's, so code typed against the plain
|
|
22
|
+
response interface takes this one as it is. A copy keeps the transfer
|
|
23
|
+
info and the very same body.
|
|
24
|
+
|
|
25
|
+
What a client adds:
|
|
26
|
+
|
|
27
|
+
- **A status is an answer, not a failure.** The response is handed back
|
|
28
|
+
once the status line and the headers are in, whatever the status.
|
|
29
|
+
:meth:`get_content` and :meth:`to_array` raise for a 3xx, 4xx or 5xx
|
|
30
|
+
when ``throw`` is left on, and return the body when it is turned off.
|
|
31
|
+
- **The body is kept.** It is read off the network on demand and kept —
|
|
32
|
+
in a temporary file past 2 MiB — so it can be read again;
|
|
33
|
+
``get_body()`` is an async stream over that kept body.
|
|
34
|
+
- **It holds a connection until it is done with.** Leaving an
|
|
35
|
+
``async with`` block closes the body, which frees the connection;
|
|
36
|
+
:meth:`cancel` does the same mid-transfer.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def get_info(self, key: str | None = None) -> object:
|
|
40
|
+
"""Return what the transfer knows about itself, as it stands.
|
|
41
|
+
|
|
42
|
+
Never raises and never waits, so it can be read while the transfer is
|
|
43
|
+
going on.
|
|
44
|
+
|
|
45
|
+
These entries are always there:
|
|
46
|
+
|
|
47
|
+
- ``canceled`` (``bool``) — whether :meth:`cancel` was called.
|
|
48
|
+
- ``error`` (``str | None``) — what went wrong, once something has.
|
|
49
|
+
- ``http_code`` (``int``) — the last status code, or ``0`` before one
|
|
50
|
+
arrived.
|
|
51
|
+
- ``http_method`` (``str``) — the method of the last request made.
|
|
52
|
+
- ``redirect_count`` (``int``) — how many redirects were followed.
|
|
53
|
+
- ``redirect_url`` (``str | None``) — where a 3xx points, resolved,
|
|
54
|
+
when it was not followed.
|
|
55
|
+
- ``response_headers`` (``list[str]``) — the raw status and header
|
|
56
|
+
lines of every hop.
|
|
57
|
+
- ``start_time`` (``float``) — when the request was sent.
|
|
58
|
+
- ``total_time`` (``float``) — how long the transfer has taken.
|
|
59
|
+
- ``url`` (``str``) — the URL of the last hop.
|
|
60
|
+
- ``user_data`` — the ``user_data`` option, or ``None``.
|
|
61
|
+
|
|
62
|
+
``primary_ip`` and ``retry_count`` are there when they are known.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: One entry to look up. ``None`` asks for all of them.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
A mapping of every entry when ``key`` is ``None``; otherwise the
|
|
69
|
+
value under ``key``, or ``None`` when there is no such entry.
|
|
70
|
+
"""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
async def cancel(self) -> None:
|
|
74
|
+
"""Abandon the transfer and let go of what it was holding.
|
|
75
|
+
|
|
76
|
+
Afterwards ``get_info("canceled")`` reads ``True`` and
|
|
77
|
+
``get_info("error")`` reads ``Response has been canceled.``, and
|
|
78
|
+
reading the body raises :class:`TransportError
|
|
79
|
+
<xtr_http_client_contracts.exception.transport_error.TransportError>`
|
|
80
|
+
with that message.
|
|
81
|
+
"""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
async def get_content(self, throw: bool = True) -> bytes:
|
|
85
|
+
"""Read the whole body, waiting for the rest of it to arrive.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
throw: Whether an unsuccessful status is to be raised rather than
|
|
89
|
+
returned. The message is ``HTTP <code> returned for "<url>".``,
|
|
90
|
+
or the title and detail of the body when it is a JSON problem
|
|
91
|
+
document (RFC 7807, or its hydra form).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Every byte of the body, empty when there was none.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
TransportError: When the transfer failed or was cancelled.
|
|
98
|
+
RedirectionError: On a 3xx once ``max_redirects`` was reached,
|
|
99
|
+
when ``throw``.
|
|
100
|
+
ClientError: On a 4xx, when ``throw``.
|
|
101
|
+
ServerError: On a 5xx, when ``throw``.
|
|
102
|
+
"""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
async def to_array(self, throw: bool = True) -> dict[str, object]:
|
|
106
|
+
"""Read the whole body and decode it as a JSON object.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
throw: Whether an unsuccessful status is to be raised rather than
|
|
110
|
+
returned, as in :meth:`get_content`.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
What the body decoded to.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
DecodingError: When the body is empty or is not JSON.
|
|
117
|
+
JsonError: When the body is JSON but not an object.
|
|
118
|
+
TransportError: When the transfer failed or was cancelled.
|
|
119
|
+
RedirectionError: On a 3xx once ``max_redirects`` was reached,
|
|
120
|
+
when ``throw``.
|
|
121
|
+
ClientError: On a 4xx, when ``throw``.
|
|
122
|
+
ServerError: On a 5xx, when ``throw``.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
async def __aenter__(self) -> Self:
|
|
127
|
+
"""Enter a block that owns the transfer.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
This response.
|
|
131
|
+
"""
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
async def __aexit__(
|
|
135
|
+
self,
|
|
136
|
+
exc_type: type[BaseException] | None,
|
|
137
|
+
exc: BaseException | None,
|
|
138
|
+
traceback: TracebackType | None,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Close the body however the block ends, and nothing else.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
exc_type: The class of the exception the block is leaving with,
|
|
144
|
+
or ``None`` when it is leaving normally.
|
|
145
|
+
exc: That exception.
|
|
146
|
+
traceback: Its traceback.
|
|
147
|
+
"""
|
|
148
|
+
...
|