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/errors.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import UnionType
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, NamedTuple, TypeAlias
|
|
9
|
+
|
|
10
|
+
from ._engine._introspection import class_name, kind_of
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from .outcome import AssertionOutcome, Requirement
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_repr(value: object) -> str:
|
|
19
|
+
"""``repr(value)`` as an exact `str`, with any `Exception` from a broken ``__repr__`` turned into a marker.
|
|
20
|
+
|
|
21
|
+
Error rendering must survive a broken user ``__repr__``. An interrupt still propagates.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
return str.__str__(repr(value)) # a `__repr__` may return a `str` subclass whose own methods raise
|
|
25
|
+
except Exception: # any user exception here must not shadow the assertion failure being rendered
|
|
26
|
+
# the class a value taken apart was read from, since that is the one whose `__repr__` raised
|
|
27
|
+
return f"<unreprable {class_name(kind_of(value))}>"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_str(value: object) -> str:
|
|
31
|
+
"""``str(value)`` as an exact `str`, falling back to `_safe_repr` on any `Exception`."""
|
|
32
|
+
try:
|
|
33
|
+
return str.__str__(str(value))
|
|
34
|
+
except Exception:
|
|
35
|
+
return _safe_repr(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _capped_format(value: object) -> str:
|
|
39
|
+
"""``f"{value}"`` as an exact `str`, falling back to `_safe_repr` on any `Exception`.
|
|
40
|
+
|
|
41
|
+
Not `_safe_str`: ``format()`` and ``str()`` disagree on a ``numpy.float32`` and, below 3.12, on a mixed-in
|
|
42
|
+
enum, so an operand a message interpolated keeps the text it always had. Capped (`_ends_kept`): every use
|
|
43
|
+
is an operand in a message.
|
|
44
|
+
"""
|
|
45
|
+
return _ends_kept(_formatted(value))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _formatted(value: object) -> str:
|
|
49
|
+
try:
|
|
50
|
+
# a lone field hands back what `__format__` returned, which may be a `str` subclass formatting itself again
|
|
51
|
+
return str.__str__(f"{value}")
|
|
52
|
+
except Exception:
|
|
53
|
+
return _safe_repr(value)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ends_kept(text: str) -> str:
|
|
57
|
+
"""*text* as a message prints a value: whole up to 4000 characters, past that its two ends.
|
|
58
|
+
|
|
59
|
+
Whole, a value of 300 rows put 15 000 characters ahead of the predicate the message is about. Both ends
|
|
60
|
+
and not the head alone, since ``ends_with`` is about the end and a container closes there.
|
|
61
|
+
"""
|
|
62
|
+
if len(text) <= 4000 or _WHOLE_VALUES:
|
|
63
|
+
return text
|
|
64
|
+
return f"{text[:3000]}... ({len(text) - 4000} more chars) ...{text[-1000:]}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _capped(value: object) -> str:
|
|
68
|
+
"""`_safe_str` of a value a message prints, capped (`_ends_kept`)."""
|
|
69
|
+
return _ends_kept(_safe_str(value))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _capped_repr(value: object) -> str:
|
|
73
|
+
"""`_safe_repr` of a value a message prints, capped (`_ends_kept`)."""
|
|
74
|
+
return _ends_kept(_safe_repr(value))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parted(actual_text: str, other_text: str, at: int, other_at: int | None = None) -> tuple[str, str]:
|
|
78
|
+
"""Two texts a message holds against each other: whole up to 4000 characters, past that cut where they part.
|
|
79
|
+
|
|
80
|
+
*at* is where the first parts from the second by the rule of the assertion that held them, and *other_at*
|
|
81
|
+
the same place in the second where the two are not read in step. Capped by their ends, two long texts
|
|
82
|
+
that part in the middle read the same on both sides, and cut at the first raw difference a comparison
|
|
83
|
+
that ignores case or spacing showed a difference it ignores.
|
|
84
|
+
"""
|
|
85
|
+
return _around(actual_text, at), _around(other_text, at if other_at is None else other_at)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _around(text: str, at: int) -> str:
|
|
89
|
+
"""*text* whole up to 4000 characters, past that the 4000 around *at*, with what is left out counted each side."""
|
|
90
|
+
if len(text) <= 4000 or _WHOLE_VALUES:
|
|
91
|
+
return text
|
|
92
|
+
start = max(0, min(at - 2000, len(text) - 4000))
|
|
93
|
+
after = len(text) - start - 4000
|
|
94
|
+
before = f"({start} more chars) ..." if start else ""
|
|
95
|
+
return f"{before}{text[start : start + 4000]}{f'... ({after} more chars)' if after else ''}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _callable_name(value: object) -> str:
|
|
99
|
+
"""A display name for a callable, tolerating those without ``__name__`` (partials, instances)."""
|
|
100
|
+
return getattr(value, "__name__", None) or _safe_repr(value)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _type_expression_name(expected: object) -> str:
|
|
104
|
+
"""A readable name for a type expression, on every Python this package supports.
|
|
105
|
+
|
|
106
|
+
``__name__`` is absent on a union below 3.14 and on a tuple of types on every version, so reading
|
|
107
|
+
it directly turns a failure message into an ``AttributeError`` - raised on the failure path, which
|
|
108
|
+
is the worst place for it. ``str()`` renders a union as ``int | str`` verbatim, which also reads
|
|
109
|
+
better than the bare ``Union`` that 3.14 started reporting.
|
|
110
|
+
|
|
111
|
+
A tuple is the third thing ``isinstance`` accepts, and its ``str()`` is a wall of ``<class '...'>``;
|
|
112
|
+
naming its members is what the reader is after.
|
|
113
|
+
"""
|
|
114
|
+
if isinstance(expected, tuple):
|
|
115
|
+
return ", ".join(_type_expression_name(member) for member in expected)
|
|
116
|
+
if isinstance(expected, UnionType):
|
|
117
|
+
return " | ".join(_type_expression_name(member) for member in expected.__args__)
|
|
118
|
+
return expected.__name__ if isinstance(expected, type) else str(expected)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _truncated(text: str, limit: int | None = None) -> str:
|
|
122
|
+
"""Cap *text* for embedding into a failure message; normal-sized values stay byte-identical.
|
|
123
|
+
|
|
124
|
+
Bounds only the rendered message: the structured payload (`AssertionFailure.actual` /
|
|
125
|
+
``.expected`` / ``.diff``) always keeps the full data. With no *limit* given the text is a value of a
|
|
126
|
+
message, cut at 4000 characters and printed whole where `_WHOLE_VALUES` says so.
|
|
127
|
+
"""
|
|
128
|
+
if limit is None:
|
|
129
|
+
if _WHOLE_VALUES:
|
|
130
|
+
return text
|
|
131
|
+
limit = 4000
|
|
132
|
+
if len(text) <= limit:
|
|
133
|
+
return text
|
|
134
|
+
return f"{text[:limit]}... ({len(text) - limit} more chars)"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _disambiguated(actual: object, other: object) -> tuple[str, str]:
|
|
138
|
+
"""Render two *unequal* values, tagging each with its ``:type`` when their plain reprs would collide.
|
|
139
|
+
|
|
140
|
+
So ``is_equal_to`` on ``"1"`` vs ``1`` reads ``<1:str>`` / ``<1:int>`` instead of a baffling
|
|
141
|
+
``<1>`` / ``<1>``, ``but was not`` - the difference is the type, and now the message says so.
|
|
142
|
+
Two classes of one name are told apart by their modules.
|
|
143
|
+
"""
|
|
144
|
+
actual_str, other_str = _truncated(_safe_str(actual)), _truncated(_safe_str(other))
|
|
145
|
+
if actual_str == other_str:
|
|
146
|
+
names = _class_names(actual, other) or (class_name(type(actual)), class_name(type(other)))
|
|
147
|
+
return f"{actual_str}:{names[0]}", f"{other_str}:{names[1]}"
|
|
148
|
+
return actual_str, other_str
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _class_names(actual: object, expected: object) -> tuple[str, str] | None:
|
|
152
|
+
"""What tells the classes of two values apart: their names, or their modules too where the names are one.
|
|
153
|
+
|
|
154
|
+
``None`` for two values of one class, and for two classes nothing a reader would see tells apart. A value
|
|
155
|
+
taken apart for a comparison answers with the class it was read from.
|
|
156
|
+
"""
|
|
157
|
+
one, other = kind_of(actual), kind_of(expected)
|
|
158
|
+
if one is other:
|
|
159
|
+
return None
|
|
160
|
+
names = class_name(one), class_name(other)
|
|
161
|
+
if names[0] != names[1]:
|
|
162
|
+
return names
|
|
163
|
+
qualified = f"{_module_name(one)}.{names[0]}", f"{_module_name(other)}.{names[1]}"
|
|
164
|
+
return qualified if qualified[0] != qualified[1] else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _module_name(kind: type) -> str:
|
|
168
|
+
"""The module a class says it is of, read off the slot, or ``?`` for a class made without one."""
|
|
169
|
+
try:
|
|
170
|
+
return _safe_str(type.__dict__["__module__"].__get__(kind))
|
|
171
|
+
except AttributeError:
|
|
172
|
+
return "?"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _told_apart(actual_text: str, expected_text: str, actual: object, expected: object) -> tuple[str, str]:
|
|
176
|
+
"""Two renderings of a pair, each followed by ``:class`` where they read alike and the classes differ.
|
|
177
|
+
|
|
178
|
+
A row of two sides that print the same says nothing, and the reader takes it for a fault of the diff: a dict
|
|
179
|
+
of a class of its own against a plain one under ``strict_types``, two records of two modules' ``Row``.
|
|
180
|
+
"""
|
|
181
|
+
if actual_text != expected_text:
|
|
182
|
+
return actual_text, expected_text
|
|
183
|
+
names = _class_names(actual, expected)
|
|
184
|
+
if names is None:
|
|
185
|
+
return actual_text, expected_text
|
|
186
|
+
return f"{actual_text}:{names[0]}", f"{expected_text}:{names[1]}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
_JsonSafe: TypeAlias = bool | int | float | str | list["_JsonSafe"] | dict[str, "_JsonSafe"] | None
|
|
190
|
+
"""Declared rather than inferred: a recursive return type is resolved per call site, and moving one
|
|
191
|
+
call moved two unrelated diagnostics in another module.
|
|
192
|
+
|
|
193
|
+
Only the recursive reference is quoted. Written as one string, ty ignores the alias and reads the
|
|
194
|
+
return as `Divergent`, and a misuse of the result went uncaught."""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _json_safe(value, _depth=0, _seen=None) -> _JsonSafe:
|
|
198
|
+
"""Convert *value* to JSON-native data for an attachment: typed where possible, bounded and total.
|
|
199
|
+
|
|
200
|
+
Scalars and containers pass through as real JSON values so the Allure viewer renders a tree and
|
|
201
|
+
consumers can parse them. Everything else degrades to a marked fallback instead of failing the
|
|
202
|
+
attachment: ``{"__repr__": ...}`` for non-JSON values (objects, datetimes, non-finite floats,
|
|
203
|
+
integers too long to print, values whose own methods raise, cycles, over-deep nesting), the snapshot
|
|
204
|
+
codec's ``{"__type__": "set", "__data__": [...]}`` envelope for sets, and `_truncated` caps on strings
|
|
205
|
+
and container sizes.
|
|
206
|
+
|
|
207
|
+
Total means an `Exception` from a value's own method degrades that value alone. An interrupt such as
|
|
208
|
+
`KeyboardInterrupt` is not a value's failure and still propagates. An object built to make the
|
|
209
|
+
conversion's own reads raise, such as a key whose `__class__` property raises, can still cost the
|
|
210
|
+
container holding it: that is a boundary, not a goal.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
return _json_native(value, _depth, set() if _seen is None else _seen)
|
|
214
|
+
except Exception: # a value's own method raised, and one value must not cost the whole attachment
|
|
215
|
+
return {"__repr__": _truncated(_safe_repr(value), 4000)}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _json_native(value, depth: int, seen: set[int]) -> _JsonSafe:
|
|
219
|
+
"""The conversion itself, which `_json_safe` keeps total by catching what a value's own methods raise."""
|
|
220
|
+
if value is None or isinstance(value, bool):
|
|
221
|
+
return value
|
|
222
|
+
if isinstance(value, int):
|
|
223
|
+
int.__repr__(value) # raises past `sys.get_int_max_str_digits()`, where `json.dumps` would
|
|
224
|
+
return value
|
|
225
|
+
if isinstance(value, float):
|
|
226
|
+
return value if math.isfinite(value) else {"__repr__": repr(value)}
|
|
227
|
+
if isinstance(value, str):
|
|
228
|
+
return _truncated(value, 4000)
|
|
229
|
+
if depth >= 6:
|
|
230
|
+
return {"__repr__": _truncated(_safe_repr(value), 4000)}
|
|
231
|
+
if id(value) in seen:
|
|
232
|
+
return {"__repr__": "<circular ref>"}
|
|
233
|
+
if isinstance(value, dict):
|
|
234
|
+
inner = seen | {id(value)}
|
|
235
|
+
items = list(value.items())
|
|
236
|
+
shown, dropped = items[:100], max(0, len(items) - 100)
|
|
237
|
+
if any(not isinstance(key, str) for key in value):
|
|
238
|
+
# the codec's own envelope: rendered as text, `1` and `"1"` were the same key and one was lost
|
|
239
|
+
envelope: dict[str, _JsonSafe] = {
|
|
240
|
+
"__type__": "dict",
|
|
241
|
+
"__data__": [
|
|
242
|
+
[_json_safe(key, depth + 1, inner), _json_safe(val, depth + 1, inner)] for key, val in shown
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
if dropped:
|
|
246
|
+
envelope["__truncated__"] = f"... and {dropped} more keys"
|
|
247
|
+
return envelope
|
|
248
|
+
rendered: dict[str, _JsonSafe] = {str.__str__(key): _json_safe(val, depth + 1, inner) for key, val in shown}
|
|
249
|
+
if dropped:
|
|
250
|
+
rendered["__truncated__"] = f"... and {dropped} more keys"
|
|
251
|
+
return rendered
|
|
252
|
+
if isinstance(value, (list, tuple)):
|
|
253
|
+
inner = seen | {id(value)}
|
|
254
|
+
out = [_json_safe(item, depth + 1, inner) for item in value[:100]]
|
|
255
|
+
if len(value) > 100:
|
|
256
|
+
out.append({"__repr__": f"... and {len(value) - 100} more items"})
|
|
257
|
+
return out
|
|
258
|
+
if isinstance(value, (set, frozenset)):
|
|
259
|
+
items = sorted(value, key=_safe_repr)
|
|
260
|
+
return {"__type__": "set", "__data__": [_json_safe(item, depth + 1, seen) for item in items[:100]]}
|
|
261
|
+
return {"__repr__": _truncated(_safe_repr(value), 4000)}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class DanglingAssertionWarning(UserWarning):
|
|
265
|
+
"""An ``assert_that()`` statement that asserts nothing.
|
|
266
|
+
|
|
267
|
+
Found at collection under ``--assertpy-dangling`` and emitted when the test that contains it is
|
|
268
|
+
set up, so pytest attributes it to that test rather than to whichever ran first. A separate
|
|
269
|
+
category so it can be turned into an error on its own with
|
|
270
|
+
``-W error::assertpy.DanglingAssertionWarning``, the same way the vacuity and snapshot-key
|
|
271
|
+
warnings are escalated.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class VacuousAssertionWarning(UserWarning):
|
|
276
|
+
"""Emitted when a universal assertion passes because there was nothing to check.
|
|
277
|
+
|
|
278
|
+
``all_satisfy`` over an empty collection is true the way ``all([])`` is true, so a query that
|
|
279
|
+
returned no rows makes the assertion pass without examining anything. That is the most common
|
|
280
|
+
silent false pass in a test suite, and the one a green run never reveals. Raise it to a failure
|
|
281
|
+
with ``-W error::assertpy.VacuousAssertionWarning`` or silence it per call with ``allow_empty``.
|
|
282
|
+
|
|
283
|
+
``is_equal_to`` under ``ignore`` or ``include`` emits it too, when the filter left no key of the
|
|
284
|
+
compared dict or object to compare.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class Step(NamedTuple):
|
|
289
|
+
"""One hop from a value to one of its parts, as `DiffEntry.steps` records it.
|
|
290
|
+
|
|
291
|
+
``path`` is written for a person and is lossy by construction: a mapping key goes through ``str()``,
|
|
292
|
+
so ``{3: ...}`` and ``{"3": ...}`` land on the same text, and a key holding a dot or a bracket
|
|
293
|
+
cannot be read back out. A step keeps the key itself, so a reader can walk back into the value it
|
|
294
|
+
came from instead of parsing a string that was never a grammar.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
kind: Literal["key", "index", "attr", "item", "line", "json"]
|
|
298
|
+
"""What kind of hop this is.
|
|
299
|
+
|
|
300
|
+
``key`` indexes a mapping, ``index`` a sequence, ``attr`` reads a field of a dataclass, namedtuple,
|
|
301
|
+
attrs class or model. ``item`` names a member of a set or of another collection that keeps no positions,
|
|
302
|
+
and the mapping key a difference lies inside.
|
|
303
|
+
``line`` is the 1-based line number of a text or bytes diff.
|
|
304
|
+
``json`` stands where the value is JSON text and the difference lies in what it holds: the steps after it lead
|
|
305
|
+
into what the text decodes to.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
value: object
|
|
309
|
+
"""The key, index, field name, member or line number. Not stringified: that is the whole point.
|
|
310
|
+
|
|
311
|
+
For ``json`` it is what the text decodes to, the very object the steps after it lead into: decoding the text
|
|
312
|
+
again would give an equal value and another object.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
side: Literal["actual", "expected"] | None = None
|
|
316
|
+
"""Which sequence the index belongs to, when the two have shifted apart.
|
|
317
|
+
|
|
318
|
+
Sequence alignment reports an inserted element against one side only, and once the two index spaces
|
|
319
|
+
disagree an index without a side names two different elements. ``None`` whenever both sides share
|
|
320
|
+
the position, which is every step that is not a one-sided element of an aligned sequence.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
325
|
+
class DiffEntry:
|
|
326
|
+
"""Single difference between actual and expected values at a specific path."""
|
|
327
|
+
|
|
328
|
+
path: str
|
|
329
|
+
actual: object = None
|
|
330
|
+
expected: object = None
|
|
331
|
+
absent: Literal["actual", "expected"] | None = None
|
|
332
|
+
"""Which side had no value here at all, as opposed to holding ``None``.
|
|
333
|
+
|
|
334
|
+
Without this the two are indistinguishable, since both leave the field at ``None``, and a
|
|
335
|
+
dictionary compared against one whose value is ``None`` renders exactly like a dictionary with an
|
|
336
|
+
extra key. Readers of a diff have to be able to tell "this key is not there" from "this key is
|
|
337
|
+
there and its value is None", and so does anything reasoning about the diff afterwards.
|
|
338
|
+
|
|
339
|
+
Defaults to ``None``, so an entry built the old way keeps its old meaning and only the producers
|
|
340
|
+
that mean absence say so.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
steps: tuple[Step, ...] = ()
|
|
344
|
+
"""The same location as ``path``, in the form a program can use.
|
|
345
|
+
|
|
346
|
+
Empty at the root, which is the entry ``path`` renders as ``.``: the difference is the whole value.
|
|
347
|
+
Also empty on an entry whose ``path`` is a label rather than a location, which is what a containment
|
|
348
|
+
or matcher failure produces.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def __str__(self) -> str:
|
|
352
|
+
actual = _truncated(_safe_str(self.actual))
|
|
353
|
+
expected = _truncated(_safe_str(self.expected))
|
|
354
|
+
return f" at {self.path}: actual=<{actual}>, expected=<{expected}>"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
358
|
+
class DiffResult:
|
|
359
|
+
"""Structured diff between two values.
|
|
360
|
+
|
|
361
|
+
``kind`` names the diff *category* - the shape of comparison that produced the entries. It is one
|
|
362
|
+
of ``"dict"``, ``"sequence"``, ``"dataclass"``, ``"namedtuple"``, ``"model"``, ``"attrs"``,
|
|
363
|
+
``"set"``, ``"string"``, ``"scalar"``, ``"contains"``, ``"match"``, or ``"openapi"``.
|
|
364
|
+
|
|
365
|
+
It is unrelated to the assertion builder's ``kind`` argument, which selects the failure mode
|
|
366
|
+
(``None``/``"soft"``/``"warn"``).
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
kind: str
|
|
370
|
+
entries: list[DiffEntry] = field(default_factory=list)
|
|
371
|
+
|
|
372
|
+
def __str__(self) -> str:
|
|
373
|
+
return _render_diff(self)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _diff_side(value: object, limit: int = 400) -> str:
|
|
377
|
+
"""Repr of one side of a diff entry, capped.
|
|
378
|
+
|
|
379
|
+
A diff row is the detail beneath a message that already carries the elided value, so it is read on
|
|
380
|
+
a terminal line and never needs the whole payload. Uncapped it is the whole payload: two 500 KB
|
|
381
|
+
strings used to render a megabyte per entry, and the structured `DiffEntry` still holds the
|
|
382
|
+
untouched values for anything that wants them.
|
|
383
|
+
"""
|
|
384
|
+
return _truncated(_safe_repr(value), limit)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _diff_sides(actual: object, expected: object, limit: int = 400) -> tuple[str, str]:
|
|
388
|
+
"""Both sides of one comparison, capped onto the difference rather than from the start.
|
|
389
|
+
|
|
390
|
+
`_diff_side` caps each side on its own, which is right for a row that stands alone. A pair read
|
|
391
|
+
together needs the pair's window: two 10 000-character strings differing in the middle were both
|
|
392
|
+
cut at character 400, so the section printed two identical-looking values under a heading saying
|
|
393
|
+
they were not equal, while the diff below it pointed straight at the change. Two sides that read alike
|
|
394
|
+
all through are told apart by their classes (`_told_apart`).
|
|
395
|
+
"""
|
|
396
|
+
return _told_apart(*_windowed(_safe_repr(actual), _safe_repr(expected), limit), actual, expected)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _first_difference(actual: str, expected: str) -> int:
|
|
400
|
+
"""The index where the two texts first differ, or the length of the shorter one.
|
|
401
|
+
|
|
402
|
+
By halving rather than by comparing character by character: the per-character loop runs in Python
|
|
403
|
+
while a slice compares in C, and over a megabyte of shared prefix that is 12.7 ms against 0.2 ms.
|
|
404
|
+
The cost is paid on the failure path only, and the worst case is small enough to accept: a difference
|
|
405
|
+
in the first position costs 0.095 ms, where the character walk leaves at once.
|
|
406
|
+
"""
|
|
407
|
+
low, high = 0, min(len(actual), len(expected))
|
|
408
|
+
while low < high:
|
|
409
|
+
middle = (low + high) // 2
|
|
410
|
+
if actual[low : middle + 1] == expected[low : middle + 1]:
|
|
411
|
+
low = middle + 1
|
|
412
|
+
else:
|
|
413
|
+
high = middle
|
|
414
|
+
return low
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _windowed(actual: str, expected: str, width: int = 160) -> tuple[str, str]:
|
|
418
|
+
"""Both lines cut to a window around their first difference.
|
|
419
|
+
|
|
420
|
+
Cutting a long line at its start hides the very thing being reported when the change is deep
|
|
421
|
+
inside it, which is the common shape for a page source or a response body.
|
|
422
|
+
"""
|
|
423
|
+
if len(actual) <= width and len(expected) <= width:
|
|
424
|
+
return actual, expected
|
|
425
|
+
first_change = _first_difference(actual, expected)
|
|
426
|
+
start = max(0, first_change - width // 2)
|
|
427
|
+
|
|
428
|
+
def cut(text: str) -> str:
|
|
429
|
+
head = "..." if start else ""
|
|
430
|
+
hidden = len(text) - min(start + width, len(text)) + start
|
|
431
|
+
tail = f"... ({hidden} more chars)" if start + width < len(text) else ""
|
|
432
|
+
return f"{head}{text[start : start + width]}{tail}"
|
|
433
|
+
|
|
434
|
+
return cut(actual), cut(expected)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _match_line(entry: DiffEntry, *, red: str, cyan: str, reset: str) -> str:
|
|
438
|
+
"""One row of a ``match`` diff: what was found against what was asked of it, or what nothing was asked of."""
|
|
439
|
+
was = _diff_side(entry.actual)
|
|
440
|
+
if entry.absent == "expected":
|
|
441
|
+
return f" {red}{entry.path}: - {was}{reset}"
|
|
442
|
+
return f" {cyan}{entry.path}{reset}: expected {entry.expected}, but was {red}{was}{reset}"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _append_string_entry(lines: list[str], entry: DiffEntry, *, red: str, green: str, reset: str) -> None:
|
|
446
|
+
"""Render one string line-pair with difflib's intra-line caret guides, the way pytest does.
|
|
447
|
+
|
|
448
|
+
A one-token change in a long line shows the exact span (``? ^^^``) instead of dumping both whole
|
|
449
|
+
lines and leaving the reader to spot the difference.
|
|
450
|
+
"""
|
|
451
|
+
if entry.absent == "expected":
|
|
452
|
+
lines.append(f" {red}{entry.path}: - {_diff_side(entry.actual)}{reset}")
|
|
453
|
+
return
|
|
454
|
+
if entry.absent == "actual":
|
|
455
|
+
lines.append(f" {green}{entry.path}: + {_diff_side(entry.expected)}{reset}")
|
|
456
|
+
return
|
|
457
|
+
# ndiff costs ~175x a plain pair, so it was skipped past 200 characters; the window bounds its input instead
|
|
458
|
+
actual_line, expected_line = _windowed(_safe_str(entry.actual), _safe_str(entry.expected))
|
|
459
|
+
lines.append(f" {entry.path}:")
|
|
460
|
+
for guide in difflib.ndiff([actual_line], [expected_line]):
|
|
461
|
+
text = guide.rstrip("\n")
|
|
462
|
+
if guide.startswith("-"):
|
|
463
|
+
lines.append(f" {red}{text}{reset}")
|
|
464
|
+
elif guide.startswith("+"):
|
|
465
|
+
lines.append(f" {green}{text}{reset}")
|
|
466
|
+
else: # the "? ^^^" caret guide row (ndiff emits nothing else for a changed single line)
|
|
467
|
+
lines.append(f" {text}")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _both_texts(actual: object, expected: object) -> bool:
|
|
471
|
+
"""Whether a diff entry pairs two text-like leaves, so a caret guide would mean something.
|
|
472
|
+
|
|
473
|
+
A caret points at the character that moved. That only reads as an answer when both sides are the
|
|
474
|
+
same kind of text; against a number or a list it would point into a repr the reader is not
|
|
475
|
+
comparing character by character.
|
|
476
|
+
"""
|
|
477
|
+
# by the type itself: `isinstance` reads a ``__class__`` the value may answer with code of its own
|
|
478
|
+
texts = (str, bytes, bytearray)
|
|
479
|
+
return issubclass(type(actual), texts) and issubclass(type(expected), texts)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _append_text_leaf(lines: list[str], entry: DiffEntry, *, red: str, green: str, reset: str) -> None:
|
|
483
|
+
"""Render a text leaf nested inside a container with the same caret guide a bare string gets.
|
|
484
|
+
|
|
485
|
+
Without this, a one-character change deep inside a URL or a token printed as two near-identical
|
|
486
|
+
rows and left the reader to spot it. pytest only reaches this at ``-vv``, and then by diffing the
|
|
487
|
+
whole pretty-printed structure, which loses the path; naming the path *and* the character is the
|
|
488
|
+
reason to do it here.
|
|
489
|
+
|
|
490
|
+
The reprs are what gets compared, not the raw values, so the quoting that separates ``"1"`` from
|
|
491
|
+
``1`` survives and the row still reads like every other row of the block.
|
|
492
|
+
|
|
493
|
+
The window is taken over the *whole* repr rather than a capped one. Capping first cuts at a fixed
|
|
494
|
+
offset, and a difference past that offset leaves two identical strings: ndiff then reports them as
|
|
495
|
+
one common row, and a failure renders as though nothing differed. Windowing first puts the
|
|
496
|
+
difference inside the window by construction, and the window is already short enough to bound the
|
|
497
|
+
cost of the ndiff below.
|
|
498
|
+
"""
|
|
499
|
+
actual_line, expected_line = _windowed(_safe_repr(entry.actual), _safe_repr(entry.expected))
|
|
500
|
+
lines.append(f" {entry.path}:")
|
|
501
|
+
for guide in difflib.ndiff([actual_line], [expected_line]):
|
|
502
|
+
text = guide.rstrip("\n")
|
|
503
|
+
if guide.startswith("-"):
|
|
504
|
+
lines.append(f" {red}{text}{reset}")
|
|
505
|
+
elif guide.startswith("+"):
|
|
506
|
+
lines.append(f" {green}{text}{reset}")
|
|
507
|
+
else: # the "? ^^^" caret row
|
|
508
|
+
lines.append(f" {text}")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _within_budget(lines: list[str], limit: int = 20_000) -> str:
|
|
512
|
+
"""Join *lines*, cutting the block once it would outgrow a screenful of scrollback.
|
|
513
|
+
|
|
514
|
+
The per-row cap in `_diff_side()` bounds one row, this bounds their sum: fifty rows of capped
|
|
515
|
+
values still add up to more than anyone reads, and the block travels into CI logs and report
|
|
516
|
+
attachments where the cost is paid again.
|
|
517
|
+
|
|
518
|
+
The row that crosses the limit is truncated rather than dropped. Dropping it whole is invisible
|
|
519
|
+
on a block of many rows and catastrophic on a block of few: the ``set`` and ``contains`` kinds
|
|
520
|
+
join every item into a single row, so a large set difference used to render as the header and a
|
|
521
|
+
count, with not one item shown.
|
|
522
|
+
"""
|
|
523
|
+
total = 0
|
|
524
|
+
for index, line in enumerate(lines):
|
|
525
|
+
remaining = limit - total
|
|
526
|
+
total += len(line) + 1
|
|
527
|
+
if total > limit:
|
|
528
|
+
dropped = len(lines) - index - 1
|
|
529
|
+
head = [*lines[:index]]
|
|
530
|
+
if remaining > 1:
|
|
531
|
+
head.append(_cut(line, remaining - 1))
|
|
532
|
+
else:
|
|
533
|
+
dropped += 1
|
|
534
|
+
if not dropped:
|
|
535
|
+
return "\n".join(head) # the trailing `...` already says where it was cut
|
|
536
|
+
return "\n".join([*head, f" ... and {dropped} more diff lines"])
|
|
537
|
+
return "\n".join(lines)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
_SGR = re.compile("\x1b\\[[0-9;]*m")
|
|
541
|
+
_RESET = "\x1b[0m"
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _cut(line: str, width: int) -> str:
|
|
545
|
+
"""``line`` truncated to ``width`` characters, left safe to print.
|
|
546
|
+
|
|
547
|
+
A colored row carries its reset at the end, so cutting one blind ends the block mid-color and
|
|
548
|
+
stains everything printed after it. The cut can also land inside an escape sequence, which the
|
|
549
|
+
terminal then completes by swallowing whatever follows it.
|
|
550
|
+
"""
|
|
551
|
+
kept = line[:width]
|
|
552
|
+
last_escape = kept.rfind("\x1b")
|
|
553
|
+
if last_escape != -1 and not _SGR.match(kept, last_escape):
|
|
554
|
+
kept = kept[:last_escape] # a half-written escape is worse than none
|
|
555
|
+
sequences = _SGR.findall(kept)
|
|
556
|
+
if sum(1 for sequence in sequences if sequence != _RESET) > sequences.count(_RESET):
|
|
557
|
+
return f"{kept}...{_RESET}"
|
|
558
|
+
return f"{kept}..."
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _render_diff(diff: object, *, color: bool = False, max_entries: int = 50) -> str:
|
|
562
|
+
"""Render a `DiffResult` as aligned ``- actual`` / ``+ expected`` lines, optionally colored.
|
|
563
|
+
|
|
564
|
+
Single source of truth for every place a diff surfaces: the pytest report section, the plain-text
|
|
565
|
+
`AssertionFailure` message off pytest, and ``str(DiffResult)``. So the diff reads identically wherever
|
|
566
|
+
it is shown.
|
|
567
|
+
"""
|
|
568
|
+
entries = getattr(diff, "entries", None)
|
|
569
|
+
if not entries:
|
|
570
|
+
return ""
|
|
571
|
+
kind = getattr(diff, "kind", "unknown")
|
|
572
|
+
|
|
573
|
+
red = "\033[31m" if color else ""
|
|
574
|
+
green = "\033[32m" if color else ""
|
|
575
|
+
cyan = "\033[36m" if color else ""
|
|
576
|
+
reset = "\033[0m" if color else ""
|
|
577
|
+
|
|
578
|
+
truncated = 0
|
|
579
|
+
visible = entries
|
|
580
|
+
if max_entries > 0 and len(entries) > max_entries:
|
|
581
|
+
truncated = len(entries) - max_entries
|
|
582
|
+
visible = entries[:max_entries]
|
|
583
|
+
|
|
584
|
+
lines = [f"{cyan}diff ({kind}):{reset}"]
|
|
585
|
+
|
|
586
|
+
if kind == "string":
|
|
587
|
+
for entry in visible:
|
|
588
|
+
_append_string_entry(lines, entry, red=red, green=green, reset=reset)
|
|
589
|
+
elif kind == "match":
|
|
590
|
+
lines.extend(_match_line(entry, red=red, cyan=cyan, reset=reset) for entry in visible)
|
|
591
|
+
elif kind in {"set", "contains"}:
|
|
592
|
+
# `absent` rather than the rendered label: a mapping key spelled "extra" used to land in the wrong group
|
|
593
|
+
extra = ", ".join(_diff_side(entry.actual) for entry in visible if entry.absent == "expected")
|
|
594
|
+
missing = ", ".join(_diff_side(entry.expected) for entry in visible if entry.absent == "actual")
|
|
595
|
+
# braces go round the members of a set: round a dict looked for in a list they read `{{'id': 99}}`
|
|
596
|
+
opener, closer = ("{", "}") if kind == "set" else ("", "")
|
|
597
|
+
if extra:
|
|
598
|
+
lines.append(f" {red}extra: {opener}{extra}{closer}{reset}")
|
|
599
|
+
if missing:
|
|
600
|
+
lines.append(f" {green}missing: {opener}{missing}{closer}{reset}")
|
|
601
|
+
else:
|
|
602
|
+
for entry in visible:
|
|
603
|
+
path = entry.path
|
|
604
|
+
if entry.absent == "expected":
|
|
605
|
+
lines.append(f" {red}{path}: - {_diff_side(entry.actual)}{reset}")
|
|
606
|
+
elif entry.absent == "actual":
|
|
607
|
+
lines.append(f" {green}{path}: + {_diff_side(entry.expected)}{reset}")
|
|
608
|
+
elif _both_texts(entry.actual, entry.expected) and _safe_repr(entry.actual) != _safe_repr(entry.expected):
|
|
609
|
+
_append_text_leaf(lines, entry, red=red, green=green, reset=reset)
|
|
610
|
+
else:
|
|
611
|
+
# as a pair: each side capped alone, two long values differing past the cap printed alike
|
|
612
|
+
actual_side, expected_side = _diff_sides(entry.actual, entry.expected)
|
|
613
|
+
lines.append(f" {path}:")
|
|
614
|
+
lines.append(f" {red}- {actual_side}{reset}")
|
|
615
|
+
lines.append(f" {green}+ {expected_side}{reset}")
|
|
616
|
+
|
|
617
|
+
if truncated:
|
|
618
|
+
lines.append(f" ... and {truncated} more entries")
|
|
619
|
+
|
|
620
|
+
return _within_budget(lines)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
_WHOLE_VALUES: bool = False
|
|
624
|
+
"""Whether a message prints a value whole, whatever its length.
|
|
625
|
+
|
|
626
|
+
The pytest plugin turns it on at ``-vv``, where pytest stops shortening its own output. The cap keeps a
|
|
627
|
+
failure readable, and what it leaves out has to be within reach of someone reading the report: a row of a
|
|
628
|
+
report section and a value of a message were the only places a response body was printed.
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
_RENDER_DIFF_IN_MESSAGE: bool = True
|
|
632
|
+
"""Whether `AssertionFailure.__str__` appends the rendered diff to its message.
|
|
633
|
+
|
|
634
|
+
The pytest plugin hangs the diff on the failure as a section of its own, so it turns this off at
|
|
635
|
+
configure time to avoid showing the diff twice. Off pytest (unittest, plain scripts, CI logs) it stays on,
|
|
636
|
+
so the structured diff travels with ``str(exc)`` instead of being lost.
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
641
|
+
class PollSample:
|
|
642
|
+
"""One recorded poll of an [`eventually()`][assertpy.assertpy.AssertionBuilder.eventually] probe.
|
|
643
|
+
|
|
644
|
+
``outcome`` is ``"fail"`` (the probe returned a value and the assertion on it failed) or
|
|
645
|
+
``"error"`` (the probe raised an ignored exception before producing a value).
|
|
646
|
+
|
|
647
|
+
``value`` is a JSON-safe point-in-time snapshot of the probed value, ``None`` for ``"error"``
|
|
648
|
+
samples, and ``detail`` carries the failure message or the exception repr.
|
|
649
|
+
|
|
650
|
+
Consecutive identical polls are collapsed into one sample: ``repeats`` counts the run, and
|
|
651
|
+
``elapsed`` is its first occurrence, in seconds from the start of polling.
|
|
652
|
+
"""
|
|
653
|
+
|
|
654
|
+
elapsed: float
|
|
655
|
+
outcome: str
|
|
656
|
+
value: object
|
|
657
|
+
detail: str
|
|
658
|
+
repeats: int = 1
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
662
|
+
class PollTrace:
|
|
663
|
+
"""Convergence telemetry attached to an ``eventually()`` timeout failure.
|
|
664
|
+
|
|
665
|
+
``samples`` keeps the first and last polls, with middle entries beyond the retention window
|
|
666
|
+
counted in ``dropped``, and ``total_polls`` is the real number of polls.
|
|
667
|
+
|
|
668
|
+
``summary`` is a one-line trend classification of why the condition never held.
|
|
669
|
+
"""
|
|
670
|
+
|
|
671
|
+
samples: list[PollSample]
|
|
672
|
+
total_polls: int
|
|
673
|
+
dropped: int
|
|
674
|
+
elapsed: float
|
|
675
|
+
summary: str
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class AssertionFailure(AssertionError): # noqa: N818 # public exception name; kept for backward compatibility
|
|
679
|
+
"""Structured assertion failure with optional diff data.
|
|
680
|
+
|
|
681
|
+
Subclasses AssertionError for full backward compatibility:
|
|
682
|
+
existing ``except AssertionError`` handlers catch it transparently.
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
def __init__(
|
|
686
|
+
self,
|
|
687
|
+
message: str,
|
|
688
|
+
*,
|
|
689
|
+
actual: object = None,
|
|
690
|
+
expected: object = None,
|
|
691
|
+
diff: DiffResult | None = None,
|
|
692
|
+
trace: PollTrace | None = None,
|
|
693
|
+
requirement: Requirement | None = None,
|
|
694
|
+
failures: tuple[AssertionOutcome, ...] = (),
|
|
695
|
+
):
|
|
696
|
+
super().__init__(message)
|
|
697
|
+
self._message = message
|
|
698
|
+
self.actual = actual
|
|
699
|
+
self.expected = expected
|
|
700
|
+
self.diff = diff
|
|
701
|
+
self.trace = trace
|
|
702
|
+
self.requirement = requirement
|
|
703
|
+
"""What was asked of the value, as data: the operation, its parameters, and whether it was negated.
|
|
704
|
+
|
|
705
|
+
``None`` where no operation was asked: `fail()`, a bare `error()` carrying a message of the
|
|
706
|
+
caller's own, and a precondition of one of the few members that assert nothing on their own.
|
|
707
|
+
"""
|
|
708
|
+
self.failures = failures
|
|
709
|
+
"""The failures a soft block collected, in the order they were collected.
|
|
710
|
+
|
|
711
|
+
Empty on a failure that is about one value, which is every failure except the aggregate a
|
|
712
|
+
``soft_assertions()`` block raises when it closes. The aggregate's message is these rendered
|
|
713
|
+
into a list; this is the same thing before it became a string.
|
|
714
|
+
"""
|
|
715
|
+
self._compared: tuple[object, object] | None = None
|
|
716
|
+
"""The two values a failed equality held against each other, or ``None`` for any other failure.
|
|
717
|
+
|
|
718
|
+
The value and the operand, or under ``ignore=`` / ``include=`` the copies without the keys left out:
|
|
719
|
+
the pair ``==`` alone decided. Read by the pytest plugin for a listener that shows two values side
|
|
720
|
+
by side.
|
|
721
|
+
"""
|
|
722
|
+
self._compared_pairs: tuple[tuple[object, object] | None, ...] = ()
|
|
723
|
+
"""Beside each of `failures`, the pair its equality held against each other, or ``None``.
|
|
724
|
+
|
|
725
|
+
Under ``ignore=`` or ``include=`` that is the two copies without the keys left out, which a raised failure
|
|
726
|
+
keeps as ``_compared``: the pytest plugin shows it, so a collected failure reads as the raised one does.
|
|
727
|
+
Kept here rather than on the record so the published record's fields stay what they were.
|
|
728
|
+
"""
|
|
729
|
+
self._soft_summary: Callable[[], str] | None = None
|
|
730
|
+
"""Draws a soft block's summary with each failure's diff in block form, for a message nobody else renders.
|
|
731
|
+
|
|
732
|
+
Set by the block that raises this, and called at each read with the switch on, so the text shows the diffs as
|
|
733
|
+
they are then, as a single failure's diff does. Drawing it when raised cost a caught failure nobody printed
|
|
734
|
+
8 us for one failure and 100 us for twenty.
|
|
735
|
+
"""
|
|
736
|
+
self._outcome: AssertionOutcome | None = None
|
|
737
|
+
"""The record this failure was composed from, set by the delivery half of `error()`.
|
|
738
|
+
|
|
739
|
+
Carries what the flat attributes cannot: whether ``actual`` and ``expected`` were named by the
|
|
740
|
+
assertion or filled in from the value under test, which is what `has_expected` reads. The
|
|
741
|
+
polling and snapshot re-wraps carry the record of the failure they re-wrap. It stays ``None``
|
|
742
|
+
on a failure built without one, which `fail()` and the aggregate a soft block raises both are.
|
|
743
|
+
|
|
744
|
+
Private, and not a constructor argument, because `AssertionOutcome` is still gaining a field
|
|
745
|
+
per release. It becomes public when a caller outside this package has a reason to read it.
|
|
746
|
+
"""
|
|
747
|
+
|
|
748
|
+
__module__ = "assertpy"
|
|
749
|
+
"""Where the class says it lives, which is what a traceback prints in front of the message.
|
|
750
|
+
|
|
751
|
+
Every failure is an `AssertionFailure` now, so this prefix is on every failure line a reader sees,
|
|
752
|
+
and pytest's one-line summary is cut to the terminal width with the prefix counted first. The
|
|
753
|
+
canonical import is ``from assertpy import AssertionFailure``, so the shorter path is also the
|
|
754
|
+
truer one. The reference still documents it under `assertpy.errors`, which reads the source.
|
|
755
|
+
"""
|
|
756
|
+
|
|
757
|
+
@property
|
|
758
|
+
def has_expected(self) -> bool:
|
|
759
|
+
"""Whether the assertion named an expected value at all.
|
|
760
|
+
|
|
761
|
+
``expected is None`` cannot answer it: `is_equal_to(None)` names one and `is_not_empty()` names
|
|
762
|
+
none, and both leave the attribute at ``None``. A reporter deciding whether to show an expected
|
|
763
|
+
column needs the difference, and had to read the private record to get it.
|
|
764
|
+
|
|
765
|
+
Read from the record a failure composed through `error()` carries, which the polling and
|
|
766
|
+
snapshot re-wraps preserve. A failure built directly has none, and `fail()` and the aggregate a
|
|
767
|
+
soft block raises are built that way: there the older reading is all there is, and an
|
|
768
|
+
expectation counts as named when it is not ``None``. Both of those name none and hold ``None``,
|
|
769
|
+
so the fallback answers them correctly.
|
|
770
|
+
"""
|
|
771
|
+
if self._outcome is not None:
|
|
772
|
+
return self._outcome.has_expected
|
|
773
|
+
return self.expected is not None
|
|
774
|
+
|
|
775
|
+
def __str__(self) -> str:
|
|
776
|
+
if _RENDER_DIFF_IN_MESSAGE and self._soft_summary is not None:
|
|
777
|
+
return self._soft_summary()
|
|
778
|
+
if _RENDER_DIFF_IN_MESSAGE and self.diff is not None:
|
|
779
|
+
rendered = _render_diff(self.diff)
|
|
780
|
+
if rendered:
|
|
781
|
+
return f"{self._message}\n{rendered}"
|
|
782
|
+
return self._message
|