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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. 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))