assertpy 2.35.0__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.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/http_mixin.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""What a failure says about the HTTP response it came from, and the one step into that response's body.
|
|
2
|
+
|
|
3
|
+
Duck-typed, like the pydantic and attrs support: nothing here imports httpx, requests, Starlette, Flask
|
|
4
|
+
or Django, and installing any of them next to this library costs nothing. A response is recognised by
|
|
5
|
+
what it *is* rather than by what it is called, and by the values of those attributes rather than by
|
|
6
|
+
their names alone. Names alone are not enough: a ``DataFrame`` with columns called ``status_code`` and
|
|
7
|
+
``headers`` answers to both, a ``MagicMock`` answers to anything at all, and attaching an authoritative
|
|
8
|
+
HTTP reading to somebody else's failure is worse than saying nothing.
|
|
9
|
+
|
|
10
|
+
The provenance line reads no body and starts no I/O. That is not caution, it is the only correct rule:
|
|
11
|
+
a streaming httpx response raises ``ResponseNotRead`` when its ``.text`` is touched, a chunked requests
|
|
12
|
+
body is consumed by reading it, and a body has no size limit. A failure being reported is not the place
|
|
13
|
+
to discover any of that. Reading the body is what `decoded_as_json()` does, and calling it is the
|
|
14
|
+
caller saying they want it read.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from collections.abc import Mapping
|
|
21
|
+
from typing import TYPE_CHECKING, Final, Protocol, cast
|
|
22
|
+
|
|
23
|
+
from ._engine._mixin_base import _MixinBase
|
|
24
|
+
from ._engine._require import refuse
|
|
25
|
+
from .errors import _truncated
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from .assertpy import AssertionBuilder
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Response(Protocol):
|
|
32
|
+
"""The shape every client family shares, and all this module ever reads from a response."""
|
|
33
|
+
|
|
34
|
+
status_code: int
|
|
35
|
+
headers: object
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
__tracebackhide__ = True
|
|
39
|
+
|
|
40
|
+
# the range HTTP defines; outside it the number is somebody else's and the line would be a guess
|
|
41
|
+
_STATUS_RANGE: Final = range(100, 600)
|
|
42
|
+
_BODY_PREVIEW: Final = 120
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _mapping_shaped(value: object) -> bool:
|
|
46
|
+
"""Whether this answers to the two operations a headers mapping is used through."""
|
|
47
|
+
return hasattr(value, "keys") and hasattr(value, "__getitem__")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def response_of(value: object) -> _Response | None:
|
|
51
|
+
"""*value* when it is an HTTP response, else ``None``.
|
|
52
|
+
|
|
53
|
+
Both attributes are read and both are checked: an integer status inside the range HTTP defines, and
|
|
54
|
+
headers that behave like a mapping. ``bool`` is excluded because it is an ``int`` and no status is
|
|
55
|
+
a boolean. Every read is guarded, since a property runs the caller's code and this is asked on the
|
|
56
|
+
way to a failure that must survive whatever it does.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
status = getattr(value, "status_code", None)
|
|
60
|
+
if not isinstance(status, int) or isinstance(status, bool) or status not in _STATUS_RANGE:
|
|
61
|
+
return None
|
|
62
|
+
return cast("_Response", value) if _mapping_shaped(getattr(value, "headers", None)) else None
|
|
63
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _request_line(response: _Response) -> str:
|
|
68
|
+
"""``METHOD URL`` for the request this answered, or as much of it as the client kept.
|
|
69
|
+
|
|
70
|
+
Only the request is read, never ``response.url``: on a Django redirect that attribute is where the
|
|
71
|
+
response points, so a call to ``/private`` answered with ``302`` to ``/login`` would be reported as
|
|
72
|
+
having been a call to ``/login``. Django keeps the request as its WSGI environ rather than as an
|
|
73
|
+
object, which is the second shape read here.
|
|
74
|
+
"""
|
|
75
|
+
request = getattr(response, "request", None)
|
|
76
|
+
method = getattr(request, "method", None)
|
|
77
|
+
url = getattr(request, "url", None)
|
|
78
|
+
if method is None and url is None and isinstance(request, Mapping):
|
|
79
|
+
method, url = request.get("REQUEST_METHOD"), request.get("PATH_INFO")
|
|
80
|
+
# without the query two different calls to one endpoint read as the same call
|
|
81
|
+
query = request.get("QUERY_STRING")
|
|
82
|
+
if url is not None and query:
|
|
83
|
+
url = f"{url}?{query}"
|
|
84
|
+
parts = [str(part) for part in (method, url) if part is not None]
|
|
85
|
+
return " ".join(parts)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def response_note(response: _Response) -> str | None:
|
|
89
|
+
"""The provenance line for a failure whose value came from *response*, or ``None``.
|
|
90
|
+
|
|
91
|
+
Never the body: see the module docstring for why that rule is not negotiable.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
status = response.status_code
|
|
95
|
+
asked = _request_line(response)
|
|
96
|
+
return f"from {asked} -> {status}" if asked else f"from a response with status {status}"
|
|
97
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _content_type(response: _Response) -> str:
|
|
102
|
+
"""The declared content type, or ``""``.
|
|
103
|
+
|
|
104
|
+
Three shapes, because header containers disagree about what they are. ``get()`` where there is one,
|
|
105
|
+
since every client's container is case-insensitive through it. Then iteration, which yields names
|
|
106
|
+
in a plain mapping and ``(name, value)`` pairs in werkzeug's ``Headers``: reading a pair as a name
|
|
107
|
+
is how this silently reported that no type had been declared when one had.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
headers = cast("dict[str, str]", response.headers)
|
|
111
|
+
getter = getattr(headers, "get", None)
|
|
112
|
+
found = getter("content-type") if callable(getter) else None
|
|
113
|
+
if found:
|
|
114
|
+
return str(found)
|
|
115
|
+
for item in headers:
|
|
116
|
+
name, value = item if isinstance(item, tuple) and len(item) == 2 else (item, None)
|
|
117
|
+
if str(name).lower() == "content-type":
|
|
118
|
+
return str(headers[name] if value is None else value)
|
|
119
|
+
except Exception: # a header mapping of somebody else's may raise from anything
|
|
120
|
+
return ""
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _NoBodyError(Exception):
|
|
125
|
+
"""The response keeps its body under none of the names read here, so there is nothing to parse."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _raw_body(response: _Response) -> object:
|
|
129
|
+
"""The body as the client kept it, under whichever of the four names this client uses."""
|
|
130
|
+
for name in ("text", "content", "data", "body"):
|
|
131
|
+
found = getattr(response, name, None)
|
|
132
|
+
if found is not None and not callable(found):
|
|
133
|
+
return found
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _preview(response: _Response) -> str | None:
|
|
138
|
+
"""How the body starts, or ``None`` when it cannot be read at all.
|
|
139
|
+
|
|
140
|
+
A body that was never read raises from every name it might be under, and this runs while a refusal
|
|
141
|
+
is being built: losing the refusal to the preview would be the worst of both. ``None`` and the
|
|
142
|
+
empty string are different answers here, since a body that is empty was read and one that raised
|
|
143
|
+
was not, and only one of those two says anything about what the body holds.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
body = _raw_body(response)
|
|
147
|
+
except Exception: # the same rule as everywhere here: the report survives whatever the value does
|
|
148
|
+
return None
|
|
149
|
+
return _truncated(str(body)[:_BODY_PREVIEW], _BODY_PREVIEW) if body is not None else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parsed(response: _Response) -> object:
|
|
153
|
+
"""The body as a document, through the client's own parser where it has one.
|
|
154
|
+
|
|
155
|
+
``json()`` in httpx, requests and both test clients; ``get_json()`` in Flask; and the raw body
|
|
156
|
+
parsed here for the clients that keep no parser of their own. A parser of somebody else's is
|
|
157
|
+
allowed to raise anything at all, so anything it raises is read as "this is not JSON" rather than
|
|
158
|
+
propagated as itself.
|
|
159
|
+
"""
|
|
160
|
+
for name in ("json", "get_json"):
|
|
161
|
+
parser = getattr(response, name, None)
|
|
162
|
+
if callable(parser):
|
|
163
|
+
document = parser()
|
|
164
|
+
# Flask answers `None` rather than raising, and a body of literal `null` parses to `None` too
|
|
165
|
+
if document is not None:
|
|
166
|
+
return document
|
|
167
|
+
break
|
|
168
|
+
body = _raw_body(response)
|
|
169
|
+
if not isinstance(body, (str, bytes, bytearray)):
|
|
170
|
+
raise _NoBodyError
|
|
171
|
+
return json.loads(body)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class HttpMixin(_MixinBase):
|
|
175
|
+
"""Assertions and steps for an HTTP response."""
|
|
176
|
+
|
|
177
|
+
def decoded_as_json(self) -> AssertionBuilder[object]:
|
|
178
|
+
"""Parse the response body and return a new builder holding the parsed document.
|
|
179
|
+
|
|
180
|
+
The step every API test takes, made part of the chain so that what it finds is asserted on with
|
|
181
|
+
the response still in hand: a failure below this line says which request it came from and what
|
|
182
|
+
that request answered.
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
Usage:
|
|
186
|
+
|
|
187
|
+
assert_that(response).decoded_as_json().matches_structure({"id": match.is_positive()})
|
|
188
|
+
assert_that(response).decoded_as_json().at_json_path("$.items[0].sku").is_equal_to("A-1")
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
AssertionBuilder: a new instance holding the parsed body. Typed over ``object`` rather than
|
|
192
|
+
over the response, for the reason `at_json_path()` is typed the way it is: a document is
|
|
193
|
+
whatever the body held, so a step that cannot know its result must not go on promising
|
|
194
|
+
the shape of its subject.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
TypeError: if val is not an HTTP response
|
|
198
|
+
ValueError: if the body is not JSON, naming the content type and how the body starts
|
|
199
|
+
"""
|
|
200
|
+
response = response_of(self.val)
|
|
201
|
+
if response is None:
|
|
202
|
+
refuse(self.val, "an HTTP response, with a status code and headers")
|
|
203
|
+
try:
|
|
204
|
+
document = _parsed(response)
|
|
205
|
+
except _NoBodyError:
|
|
206
|
+
raise TypeError("the response carries no body this can read, under text, content, data or body") from None
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
# the two together name the usual cause: an expired session answered with a login page
|
|
209
|
+
declared = _content_type(response)
|
|
210
|
+
named = f"content-type is {declared!r}" if declared else "no content type was declared"
|
|
211
|
+
preview = _preview(response)
|
|
212
|
+
if preview is None:
|
|
213
|
+
# nothing is claimed about a body nobody could read: it may well have been JSON
|
|
214
|
+
raise ValueError(f"the response body could not be read: {named}") from exc
|
|
215
|
+
shown = f"it starts with {preview!r}" if preview else "it is empty"
|
|
216
|
+
raise ValueError(f"the response body is not JSON: {named} and {shown}") from exc
|
|
217
|
+
# `builder()` is declared to hand back the same kind, wrong for a step whose result holds the document
|
|
218
|
+
return cast("AssertionBuilder[object]", self.builder(document, self.description, self.kind, logger=self.logger))
|