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/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from .assertpy import (
|
|
2
|
+
CheckBuilder,
|
|
3
|
+
NegatedBuilder,
|
|
4
|
+
SoftAssertionCollector,
|
|
5
|
+
WarningLoggingAdapter,
|
|
6
|
+
__version__,
|
|
7
|
+
add_extension,
|
|
8
|
+
assert_all,
|
|
9
|
+
assert_conforms,
|
|
10
|
+
assert_that,
|
|
11
|
+
assert_warn,
|
|
12
|
+
fail,
|
|
13
|
+
log_passes,
|
|
14
|
+
remove_extension,
|
|
15
|
+
soft_assertions,
|
|
16
|
+
soft_fail,
|
|
17
|
+
)
|
|
18
|
+
from .async_assertions import AsyncAssertionBuilder, SyncAssertionBuilder
|
|
19
|
+
from .errors import (
|
|
20
|
+
AssertionFailure,
|
|
21
|
+
DanglingAssertionWarning,
|
|
22
|
+
DiffEntry,
|
|
23
|
+
DiffResult,
|
|
24
|
+
PollSample,
|
|
25
|
+
PollTrace,
|
|
26
|
+
Step,
|
|
27
|
+
VacuousAssertionWarning,
|
|
28
|
+
)
|
|
29
|
+
from .file import contents_of
|
|
30
|
+
from .matchers import (
|
|
31
|
+
BaseMatcher,
|
|
32
|
+
Matcher,
|
|
33
|
+
MatchResult,
|
|
34
|
+
clear_custom_matchers,
|
|
35
|
+
match,
|
|
36
|
+
register_matcher,
|
|
37
|
+
unregister_matcher,
|
|
38
|
+
)
|
|
39
|
+
from .outcome import AssertionOutcome, Requirement
|
|
40
|
+
from .snapshot import (
|
|
41
|
+
SnapshotCreatedWarning,
|
|
42
|
+
SnapshotKeyReusedWarning,
|
|
43
|
+
SnapshotUpdatedWarning,
|
|
44
|
+
register_snapshot_serializer,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"AssertionFailure",
|
|
49
|
+
"AssertionOutcome",
|
|
50
|
+
"AsyncAssertionBuilder",
|
|
51
|
+
"BaseMatcher",
|
|
52
|
+
"CheckBuilder",
|
|
53
|
+
"DanglingAssertionWarning",
|
|
54
|
+
"DiffEntry",
|
|
55
|
+
"DiffResult",
|
|
56
|
+
"MatchResult",
|
|
57
|
+
"Matcher",
|
|
58
|
+
"NegatedBuilder",
|
|
59
|
+
"PollSample",
|
|
60
|
+
"PollTrace",
|
|
61
|
+
"Requirement",
|
|
62
|
+
"SnapshotCreatedWarning",
|
|
63
|
+
"SnapshotKeyReusedWarning",
|
|
64
|
+
"SnapshotUpdatedWarning",
|
|
65
|
+
"SoftAssertionCollector",
|
|
66
|
+
"Step",
|
|
67
|
+
"SyncAssertionBuilder",
|
|
68
|
+
"VacuousAssertionWarning",
|
|
69
|
+
"WarningLoggingAdapter",
|
|
70
|
+
"__version__",
|
|
71
|
+
"add_extension",
|
|
72
|
+
"assert_all",
|
|
73
|
+
"assert_conforms",
|
|
74
|
+
"assert_that",
|
|
75
|
+
"assert_warn",
|
|
76
|
+
"clear_custom_matchers",
|
|
77
|
+
"contents_of",
|
|
78
|
+
"fail",
|
|
79
|
+
"log_passes",
|
|
80
|
+
"match",
|
|
81
|
+
"register_matcher",
|
|
82
|
+
"register_snapshot_serializer",
|
|
83
|
+
"remove_extension",
|
|
84
|
+
"soft_assertions",
|
|
85
|
+
"soft_fail",
|
|
86
|
+
"unregister_matcher",
|
|
87
|
+
]
|
assertpy/_clustering.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Group a run's failures by the difference they share, so a wall of red reads as one cause.
|
|
2
|
+
|
|
3
|
+
Forty failing tests are usually not forty problems. A fixture that changed one field fails every test
|
|
4
|
+
that touches it, and pytest reports each one on its own because a rendered `AssertionError` is all it
|
|
5
|
+
has. This library keeps the comparison as structure, so the same forty can be answered in a line:
|
|
6
|
+
*thirty-seven of them differ at `user.role`*.
|
|
7
|
+
|
|
8
|
+
The grouping is by equality, never by likeness. Two failures share a cluster when their signatures are
|
|
9
|
+
equal, and a signature is built from the diff itself. There is no similarity score to tune and no
|
|
10
|
+
ordering to depend on: the same run produces the same clusters.
|
|
11
|
+
|
|
12
|
+
Where a value is identified rather than compared, what stands for it is its stable repr, so two values
|
|
13
|
+
that print alike count as one difference. That is a real limit and the honest one to state: a summary
|
|
14
|
+
cannot hold the values themselves without shipping a run's whole payload between processes.
|
|
15
|
+
|
|
16
|
+
**The key is the place, not the values, and that was measured rather than assumed.** An earlier
|
|
17
|
+
edition put the values in the key, which reads as the safer choice and quietly narrows the feature to
|
|
18
|
+
one case: a broken constant, where all forty failures show the same pair. A broken *formula* differs
|
|
19
|
+
at the same field with a value of its own per test, and keying on values scattered it into forty
|
|
20
|
+
clusters of one. Both are ordinary, and only the first was covered.
|
|
21
|
+
|
|
22
|
+
So an entry is keyed on its path whenever the path is a coordinate (`key`, `index`, `attr` or `line`
|
|
23
|
+
steps). Values are collected as examples instead, and a cluster whose values disagree says so rather
|
|
24
|
+
than printing one pair as though it explained every failure it holds.
|
|
25
|
+
|
|
26
|
+
**Three kinds of difference carry no coordinate at all**, which an audit of every diff kind this
|
|
27
|
+
library emits established:
|
|
28
|
+
|
|
29
|
+
* a `contains` failure names `missing` or `extra`, which is a label, not a place - an item that is not
|
|
30
|
+
in a list has no position in it;
|
|
31
|
+
* a `set` entry's step holds the *member*, since a set has no index to give;
|
|
32
|
+
* a `scalar` entry is the whole value, and its path renders as `.`.
|
|
33
|
+
|
|
34
|
+
Keying those on their path would put every unrelated `contains` failure in one cluster called
|
|
35
|
+
`missing`. They fall back to the diagnostic line the failure already carries - *every difference here
|
|
36
|
+
is one of surrounding whitespace* - which is the same difference stated as a kind rather than a place.
|
|
37
|
+
|
|
38
|
+
Without a diagnostic, only the kinds whose two fields really hold the compared values fall back to
|
|
39
|
+
them. `contains` and `set` do not: there the fields hold *presence*, so a missing item reports `None`
|
|
40
|
+
on the actual side, and a heading built from that tells the reader their value was `None` when it was
|
|
41
|
+
a list of three. Those two stay out of the summary rather than getting a second rendering, which was
|
|
42
|
+
the cheaper half of a measurement showing they clustered almost nothing.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import hashlib
|
|
48
|
+
from typing import TYPE_CHECKING, Final, NamedTuple
|
|
49
|
+
|
|
50
|
+
from .errors import _safe_repr
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from collections.abc import Iterable, Sequence
|
|
54
|
+
|
|
55
|
+
from .errors import DiffEntry, DiffResult
|
|
56
|
+
|
|
57
|
+
__tracebackhide__ = True
|
|
58
|
+
|
|
59
|
+
# steps that name a *place* in a value, as opposed to `item`, which names a member of a set
|
|
60
|
+
_COORDINATE: Final = frozenset({"key", "index", "attr", "line"})
|
|
61
|
+
|
|
62
|
+
# a position collapses to one placeholder: `users[0].role` and `users[7].role` are one difference on two rows
|
|
63
|
+
_POSITIONAL: Final = frozenset({"index", "line"})
|
|
64
|
+
_ANY_POSITION: Final = "[*]"
|
|
65
|
+
|
|
66
|
+
_VALUES_ARE_VALUES: Final = frozenset({"scalar", "string"})
|
|
67
|
+
"""Location-free kinds whose ``actual`` and ``expected`` hold the two values being compared.
|
|
68
|
+
|
|
69
|
+
For `contains` and `set` those fields hold *presence* instead: a missing item reports `None` on the
|
|
70
|
+
actual side, which as a cluster heading reads as a claim that the value under test was `None`. Those
|
|
71
|
+
two group only on a diagnostic, and stay out of the summary entirely without one. The alternative,
|
|
72
|
+
inventing a second rendering for them, buys a family that clustered almost nothing on measurement.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
_EXAMPLE_LIMIT: Final = 64
|
|
76
|
+
"""Distinct values a cluster keeps per side before it starts saying "and more" instead.
|
|
77
|
+
|
|
78
|
+
One past this many are actually held, so the length of the tuple is itself the answer to "was this
|
|
79
|
+
capped": at `_EXAMPLE_LIMIT + 1` the summary prints a floor rather than a count.
|
|
80
|
+
|
|
81
|
+
Twenty thousand failures differing at one field held twenty thousand example values, 888 KB, to print
|
|
82
|
+
one of them and a count. The smallest value is tracked separately so the printed example stays the
|
|
83
|
+
same whatever order the failures arrived in, which is the property the sort was there for.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
_MAX_CLUSTERS: Final = 5
|
|
87
|
+
"""Clusters the summary prints, after which it says how many it left out.
|
|
88
|
+
|
|
89
|
+
Eligibility is a property of one cluster and is decided by `MINIMUM_SIZE`. How much of the screen the
|
|
90
|
+
summary may take is a separate question, and a red run has already used the screen for tracebacks.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
_VALUE_LIMIT: Final = 200
|
|
94
|
+
"""Characters of a value the summary will print, and ship between xdist workers.
|
|
95
|
+
|
|
96
|
+
The message itself caps at 400 per side and 4000 overall. A summary line is denser than a message and
|
|
97
|
+
sits in the last screen of the run, so it caps harder: three failures over a 500 KB response body
|
|
98
|
+
otherwise put a megabyte and a half on the terminal and the same again through execnet.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
MINIMUM_SIZE: Final = 3
|
|
102
|
+
"""Failures a cluster must hold before it is worth printing.
|
|
103
|
+
|
|
104
|
+
An absolute floor, deliberately, after a share of the run's failures was tried and measured strictly
|
|
105
|
+
worse. A share means every additional cause raises the bar for all the others, so a run of forty
|
|
106
|
+
failures splitting cleanly into five causes of eight printed nothing at all - the summary went quiet
|
|
107
|
+
on exactly the runs that most needed one. A floor does not have that property, and the
|
|
108
|
+
trailing line already tells the reader how much of the run fell outside a cluster of this size.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Signature(NamedTuple):
|
|
113
|
+
"""What makes two failures the same failure.
|
|
114
|
+
|
|
115
|
+
``located`` says which family the signature belongs to, and therefore how to read ``where``: a
|
|
116
|
+
canonical path for a difference that has a position, and the diff's kind for one that does not.
|
|
117
|
+
|
|
118
|
+
``where`` is written for a reader and ``steps`` decides identity, because the two cannot be the
|
|
119
|
+
same string. A path renders a mapping key bare, so ``{3: ...}`` and ``{"3": ...}`` both read as
|
|
120
|
+
``3``, and grouping on that text would merge two differences that have nothing in common - the
|
|
121
|
+
exact ambiguity `DiffEntry.steps` exists to remove. ``steps`` keeps each key's repr, so the two
|
|
122
|
+
stay apart while the summary still prints the readable form.
|
|
123
|
+
|
|
124
|
+
``label`` and ``values`` are what separate two location-free differences, and exactly one of them
|
|
125
|
+
is ever set: the failure's own diagnostic line when it had one, the pair of values when it did
|
|
126
|
+
not. Both stay empty for a located signature, whose place is the whole key.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
located: bool
|
|
130
|
+
where: str
|
|
131
|
+
steps: tuple[tuple[str, str], ...] = ()
|
|
132
|
+
label: str = ""
|
|
133
|
+
values: tuple[str, ...] = ()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Observation(NamedTuple):
|
|
137
|
+
"""One failure's contribution to a cluster: what it is keyed on, and what it actually showed."""
|
|
138
|
+
|
|
139
|
+
signature: Signature
|
|
140
|
+
actual: str
|
|
141
|
+
expected: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class Cluster(NamedTuple):
|
|
145
|
+
"""One signature, the tests that produced it, and the values they showed.
|
|
146
|
+
|
|
147
|
+
``actuals`` and ``expecteds`` hold the distinct values *sorted*, so the summary can print the pair
|
|
148
|
+
when a cluster agrees on one and say how many there are when it does not. Sorted rather than in
|
|
149
|
+
order of appearance, because under xdist that order is the order the workers happened to finish in,
|
|
150
|
+
and the same failing run printed a different example each time.
|
|
151
|
+
|
|
152
|
+
They are counted separately on purpose: forty tests differing at `role` can hold forty actual
|
|
153
|
+
values against a single expected one, and reporting that expected value is the useful half.
|
|
154
|
+
|
|
155
|
+
A side keeps at most one past `_EXAMPLE_LIMIT` distinct values, so the tuple's own length is what
|
|
156
|
+
says the count was capped: see that constant for why the extra one is held.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
signature: Signature
|
|
160
|
+
nodeids: tuple[str, ...]
|
|
161
|
+
actuals: tuple[str, ...] = ()
|
|
162
|
+
expecteds: tuple[str, ...] = ()
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def size(self) -> int:
|
|
166
|
+
return len(self.nodeids)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def stable_repr(value: object, _seen: frozenset[int] = frozenset()) -> str:
|
|
170
|
+
"""``repr`` with set ordering pinned, so the same value reads the same in any process.
|
|
171
|
+
|
|
172
|
+
A set reprs in iteration order, which follows the hash seed, so two identical failures on two
|
|
173
|
+
xdist workers - separate processes, separate seeds - produced different signatures and never
|
|
174
|
+
clustered. The symptom was the feature going silent on exactly the runs it exists for.
|
|
175
|
+
|
|
176
|
+
Sets are sorted by their members' own stable repr, and containers are walked so a set nested
|
|
177
|
+
inside one is reached. Everything else keeps plain ``repr``: a dict's order is the reader's own,
|
|
178
|
+
and pinning it here would print a value the failure message never showed.
|
|
179
|
+
|
|
180
|
+
Walking containers means owning the two hazards `repr` already handles and the diff engine already
|
|
181
|
+
guards: a value that contains itself, and a container that raises when iterated - a detached ORM
|
|
182
|
+
collection, a lazy wrapper over a closed connection. Both used to escape from a summary hook,
|
|
183
|
+
which pytest answers with INTERNALERROR, so a whole run reported nothing at all.
|
|
184
|
+
"""
|
|
185
|
+
if id(value) in _seen:
|
|
186
|
+
return "..." # `repr` marks the same thing, wrapped in the container's own brackets
|
|
187
|
+
inner = _seen | {id(value)}
|
|
188
|
+
try:
|
|
189
|
+
if isinstance(value, (set, frozenset)):
|
|
190
|
+
members = sorted(stable_repr(member, inner) for member in value)
|
|
191
|
+
# an empty one falls back to `repr`: `{}` is a dict, so `set()` and the `frozenset()` it equals read apart
|
|
192
|
+
return "{" + ", ".join(members) + "}" if value else _safe_repr(value)
|
|
193
|
+
if isinstance(value, dict):
|
|
194
|
+
pairs = (f"{stable_repr(key, inner)}: {stable_repr(item, inner)}" for key, item in value.items())
|
|
195
|
+
return "{" + ", ".join(pairs) + "}"
|
|
196
|
+
if isinstance(value, list):
|
|
197
|
+
return "[" + ", ".join(stable_repr(item, inner) for item in value) + "]"
|
|
198
|
+
if isinstance(value, tuple):
|
|
199
|
+
walked = ", ".join(stable_repr(item, inner) for item in value)
|
|
200
|
+
return f"({walked},)" if len(value) == 1 else f"({walked})"
|
|
201
|
+
except Exception: # iterating is the user's code, and it must not outrank the failure being reported
|
|
202
|
+
return _safe_repr(value)
|
|
203
|
+
return _safe_repr(value)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def canonical_steps(entry: DiffEntry) -> tuple[tuple[str, str], ...] | None:
|
|
207
|
+
"""The entry's location as ``(kind, repr)`` hops, or ``None`` when it has no location.
|
|
208
|
+
|
|
209
|
+
Indices collapse to one placeholder, and every other hop keeps its value's repr rather than its
|
|
210
|
+
text, so two keys that render alike stay apart.
|
|
211
|
+
"""
|
|
212
|
+
# entering JSON text is no place of its own: what lies in it is found by the same keys from one run to the next
|
|
213
|
+
steps = tuple(step for step in entry.steps if step.kind != "json")
|
|
214
|
+
if not steps or any(step.kind not in _COORDINATE for step in steps):
|
|
215
|
+
return None
|
|
216
|
+
return tuple((step.kind, _ANY_POSITION if step.kind in _POSITIONAL else stable_repr(step.value)) for step in steps)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_path(steps: tuple[tuple[str, str], ...]) -> str:
|
|
220
|
+
"""The readable form of a canonical location: ``users[*].role``, ``line [n]``."""
|
|
221
|
+
parts = []
|
|
222
|
+
for kind, value in steps:
|
|
223
|
+
if kind == "index":
|
|
224
|
+
parts.append(_ANY_POSITION)
|
|
225
|
+
elif kind == "line":
|
|
226
|
+
parts.append("line [n]")
|
|
227
|
+
else:
|
|
228
|
+
plain = value[1:-1] if len(value) > 1 and value[0] == value[-1] == "'" else value
|
|
229
|
+
parts.append(f".{plain}" if parts else plain)
|
|
230
|
+
return _bounded("".join(parts))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _bounded(text: str) -> str:
|
|
234
|
+
"""*text* cut to what a summary line can carry, saying how much it dropped."""
|
|
235
|
+
return text if len(text) <= _VALUE_LIMIT else f"{text[:_VALUE_LIMIT]}... ({len(text) - _VALUE_LIMIT} more chars)"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _shown(value: object) -> str:
|
|
239
|
+
"""A value as the summary will print it: stable across processes and bounded in length."""
|
|
240
|
+
return _bounded(stable_repr(value))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class _Absent:
|
|
244
|
+
"""A side that held nothing at all.
|
|
245
|
+
|
|
246
|
+
An absent key carries `None`, and read as a value it printed as ``None`` and keyed with every
|
|
247
|
+
failure whose value really is `None`.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def __repr__(self) -> str:
|
|
251
|
+
return "<absent>"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
_ABSENT = _Absent()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _side_of(entry: DiffEntry, side: str) -> object:
|
|
258
|
+
"""One side of an entry, or the absent marker where that side held nothing."""
|
|
259
|
+
return _ABSENT if entry.absent == side else (entry.actual if side == "actual" else entry.expected)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _identity(value: object) -> str:
|
|
263
|
+
"""A value as a cluster key: the whole of it, in a fixed number of characters.
|
|
264
|
+
|
|
265
|
+
The printed form cannot serve: it is cut at `_VALUE_LIMIT`, so two payloads sharing a long prefix
|
|
266
|
+
would key alike and the summary would announce a difference they do not share. Keying on the whole
|
|
267
|
+
text instead would put a megabyte on the wire per failure, which is what the cut is there to stop.
|
|
268
|
+
A digest is both. What it gives up is stated where the module says what "the same difference" means:
|
|
269
|
+
identity is over the text a value prints as, and the chance of two different texts colliding in
|
|
270
|
+
sixteen bytes is not one a test run reaches.
|
|
271
|
+
"""
|
|
272
|
+
return hashlib.blake2b(stable_repr(value).encode("utf-8", "surrogatepass"), digest_size=16).hexdigest()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def signature(diff: DiffResult, entry: DiffEntry, label: str | None = None) -> Signature | None:
|
|
276
|
+
"""The cluster key for one entry of one failure, or ``None`` when it has nothing to share.
|
|
277
|
+
|
|
278
|
+
``label`` is the failure's diagnostic line, when it had one. It keys the location-free family,
|
|
279
|
+
where it is the only thing two differences can share once their values are set aside.
|
|
280
|
+
"""
|
|
281
|
+
steps = canonical_steps(entry)
|
|
282
|
+
if steps is not None:
|
|
283
|
+
return Signature(True, render_path(steps), steps)
|
|
284
|
+
if label:
|
|
285
|
+
return Signature(False, diff.kind, label=label)
|
|
286
|
+
if diff.kind not in _VALUES_ARE_VALUES:
|
|
287
|
+
return None
|
|
288
|
+
return Signature(
|
|
289
|
+
False, diff.kind, values=(_identity(_side_of(entry, "actual")), _identity(_side_of(entry, "expected")))
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def is_well_formed(key: Signature) -> bool:
|
|
294
|
+
"""Whether a signature is one this module could have produced.
|
|
295
|
+
|
|
296
|
+
A signature only ever arrives from outside over the xdist wire, and a payload whose every field has
|
|
297
|
+
the right type can still describe a difference that cannot exist: a located key with no steps to
|
|
298
|
+
locate it, or a location-free key holding a diagnostic and a pair of values at once. Either prints a
|
|
299
|
+
heading the structure behind it does not support, which is the one thing this summary must not do.
|
|
300
|
+
"""
|
|
301
|
+
if key.located:
|
|
302
|
+
return (
|
|
303
|
+
bool(key.steps)
|
|
304
|
+
and all(kind in _COORDINATE for kind, _ in key.steps)
|
|
305
|
+
and not key.label
|
|
306
|
+
and not key.values
|
|
307
|
+
and key.where == render_path(key.steps)
|
|
308
|
+
)
|
|
309
|
+
if key.steps:
|
|
310
|
+
return False
|
|
311
|
+
# a pair of values only for the kinds whose fields hold them, or a `contains` payload prints a `None`
|
|
312
|
+
if key.label:
|
|
313
|
+
return not key.values
|
|
314
|
+
return len(key.values) == 2 and key.where in _VALUES_ARE_VALUES
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def observations_of(diff: DiffResult | None, label: str | None = None) -> list[Observation]:
|
|
318
|
+
"""Every distinct signature one failure contributes, each with the values that produced it.
|
|
319
|
+
|
|
320
|
+
A failure differing at three fields belongs to three clusters, which is the useful reading: the
|
|
321
|
+
cluster that grows is the field they all share, and the ones that do not grow fall below the floor
|
|
322
|
+
and stay quiet.
|
|
323
|
+
"""
|
|
324
|
+
if diff is None or not diff.entries:
|
|
325
|
+
return []
|
|
326
|
+
seen: dict[Signature, Observation] = {}
|
|
327
|
+
for entry in diff.entries:
|
|
328
|
+
key = signature(diff, entry, label)
|
|
329
|
+
if key is not None:
|
|
330
|
+
seen.setdefault(
|
|
331
|
+
key, Observation(key, _shown(_side_of(entry, "actual")), _shown(_side_of(entry, "expected")))
|
|
332
|
+
)
|
|
333
|
+
return list(seen.values())
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def clusters(
|
|
337
|
+
recorded: Sequence[tuple[str, Iterable[Observation]]],
|
|
338
|
+
total_failures: int,
|
|
339
|
+
*,
|
|
340
|
+
minimum: int = MINIMUM_SIZE,
|
|
341
|
+
) -> list[Cluster]:
|
|
342
|
+
"""The clusters worth reporting, largest first.
|
|
343
|
+
|
|
344
|
+
``recorded`` is one entry per failing test: its node id and what that failure contributed.
|
|
345
|
+
``total_failures`` counts every test that went red, including those whose failure carried no diff at
|
|
346
|
+
all, because the summary reports how much of the run it accounts for. Tests rather than reports: one
|
|
347
|
+
test can go red twice, failing its assertion and then erroring in teardown, and pytest counts that as
|
|
348
|
+
a failure and an error. Counting both would let two numbers describe three tests.
|
|
349
|
+
"""
|
|
350
|
+
if total_failures <= 0:
|
|
351
|
+
return []
|
|
352
|
+
# keyed on the node id: `pytest-rerunfailures` retries printed a cluster of six over two tests
|
|
353
|
+
grouped: dict[Signature, dict[str, None]] = {}
|
|
354
|
+
actuals: dict[Signature, dict[str, None]] = {}
|
|
355
|
+
expecteds: dict[Signature, dict[str, None]] = {}
|
|
356
|
+
for nodeid, found in recorded:
|
|
357
|
+
for one in found:
|
|
358
|
+
grouped.setdefault(one.signature, {})[nodeid] = None
|
|
359
|
+
# one past the limit, so a full side is distinguishable from a capped one by its length alone
|
|
360
|
+
for store, value in ((actuals, one.actual), (expecteds, one.expected)):
|
|
361
|
+
seen = store.setdefault(one.signature, {})
|
|
362
|
+
if len(seen) <= _EXAMPLE_LIMIT:
|
|
363
|
+
seen[value] = None
|
|
364
|
+
elif value < min(seen):
|
|
365
|
+
seen.pop(max(seen))
|
|
366
|
+
seen[value] = None
|
|
367
|
+
large = [
|
|
368
|
+
Cluster(key, tuple(nodeids), tuple(sorted(actuals[key])), tuple(sorted(expecteds[key])))
|
|
369
|
+
for key, nodeids in grouped.items()
|
|
370
|
+
if len(nodeids) >= minimum
|
|
371
|
+
]
|
|
372
|
+
# size first, then the signature: two clusters of equal size must not swap places between runs
|
|
373
|
+
large.sort(key=lambda cluster: (-cluster.size, cluster.signature))
|
|
374
|
+
return large
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _spelled_out(steps: tuple[tuple[str, str], ...]) -> str:
|
|
378
|
+
"""A location written so no two of them can read alike: ``key=3`` against ``key='3'``.
|
|
379
|
+
|
|
380
|
+
Cutting it would undo that for two long keys sharing a prefix, which is the case this spelling exists
|
|
381
|
+
for, so what is cut keeps a digest of the whole: the reader gets a short pair of headings that are
|
|
382
|
+
still different from each other.
|
|
383
|
+
"""
|
|
384
|
+
spelled = ".".join(f"{kind}={value}" for kind, value in steps)
|
|
385
|
+
if len(spelled) <= _VALUE_LIMIT:
|
|
386
|
+
return spelled
|
|
387
|
+
digest = hashlib.blake2b(spelled.encode("utf-8", "surrogatepass"), digest_size=4).hexdigest()
|
|
388
|
+
return f"{spelled[:_VALUE_LIMIT]}... [{digest}]"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _headings(found: Sequence[Cluster]) -> list[str]:
|
|
392
|
+
"""One heading per cluster, disambiguated only where two would otherwise read the same.
|
|
393
|
+
|
|
394
|
+
A readable path drops the quotes around a mapping key, so ``{3: ...}`` and ``{"3": ...}`` both
|
|
395
|
+
print as ``3``. They are correctly held apart as clusters, and printing them identically hands
|
|
396
|
+
the reader two blocks that look like a rendering bug. The precise spelling is used only for the
|
|
397
|
+
clusters that collide, so the ordinary summary keeps reading as a path.
|
|
398
|
+
"""
|
|
399
|
+
plain = [one.signature.where for one in found]
|
|
400
|
+
clashing = {name for name in plain if plain.count(name) > 1}
|
|
401
|
+
spelled = [
|
|
402
|
+
_spelled_out(one.signature.steps) if name in clashing and one.signature.located else name
|
|
403
|
+
for name, one in zip(plain, found, strict=True)
|
|
404
|
+
]
|
|
405
|
+
return _made_unique(spelled)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _made_unique(names: list[str]) -> list[str]:
|
|
409
|
+
"""Number whatever still reads alike, so two headings can never be one.
|
|
410
|
+
|
|
411
|
+
The spelling above tells two locations apart by what they hold, and a location long enough to be cut
|
|
412
|
+
keeps a digest of the rest, which identifies it but cannot promise uniqueness. This can: the summary
|
|
413
|
+
is one screen of text, and two lines the reader cannot tell apart is the failure mode either half was
|
|
414
|
+
there to prevent.
|
|
415
|
+
"""
|
|
416
|
+
seen: dict[str, int] = {}
|
|
417
|
+
numbered = []
|
|
418
|
+
for name in names:
|
|
419
|
+
seen[name] = seen.get(name, 0) + 1
|
|
420
|
+
numbered.append(name if names.count(name) == 1 else f"{name} #{seen[name]}")
|
|
421
|
+
return numbered
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _headline(cluster: Cluster, where: str, total_failures: int) -> str:
|
|
425
|
+
"""A cluster's first line, in the grammar its family allows.
|
|
426
|
+
|
|
427
|
+
A located cluster has somewhere to point at. A location-free one does not, and saying they "differ
|
|
428
|
+
at (string)" claims a place where the only thing shared is the shape of the difference: the reader is
|
|
429
|
+
told they differ *in a string*, which is not what the cluster knows. The line under it carries the
|
|
430
|
+
substance either way, the diagnostic or the two values.
|
|
431
|
+
"""
|
|
432
|
+
if cluster.signature.located:
|
|
433
|
+
return f"{cluster.size} of {total_failures} failing tests differ at {where}"
|
|
434
|
+
return f"{cluster.size} of {total_failures} failing tests share one {where} difference"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _side(values: tuple[str, ...]) -> str:
|
|
438
|
+
"""One side of a cluster: the value when they agree, an example and a count when they do not.
|
|
439
|
+
|
|
440
|
+
A cluster keyed on a place can hold failures whose values differ, and printing the first of them
|
|
441
|
+
as though it were the difference is the one way this summary could mislead. Saying how many there
|
|
442
|
+
are keeps the reader's next move right: one value is a fact to act on, twelve is a place to look.
|
|
443
|
+
"""
|
|
444
|
+
first, rest = values[0], len(values) - 1
|
|
445
|
+
if not rest:
|
|
446
|
+
return first
|
|
447
|
+
if len(values) > _EXAMPLE_LIMIT: # the side was capped, so the count is a floor rather than a total
|
|
448
|
+
return f"{first} and {_EXAMPLE_LIMIT}+ other values"
|
|
449
|
+
return f"{first} and {rest} other value{'' if rest == 1 else 's'}"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def render(
|
|
453
|
+
found: Sequence[Cluster],
|
|
454
|
+
total_failures: int,
|
|
455
|
+
lost_workers: int = 0,
|
|
456
|
+
unreadable_workers: int = 0,
|
|
457
|
+
minimum: int = MINIMUM_SIZE,
|
|
458
|
+
collect_errors: int = 0,
|
|
459
|
+
) -> list[str]:
|
|
460
|
+
"""The summary lines, or an empty list when there is nothing worth saying.
|
|
461
|
+
|
|
462
|
+
``lost_workers`` counts the xdist workers that died without shipping what they had recorded, and
|
|
463
|
+
``unreadable_workers`` those that finished and shipped something this version could not read. Both
|
|
464
|
+
leave failures out of every number below, and they are counted apart because they are different
|
|
465
|
+
claims about the run: a summary that reports a crash where a worker merely spoke another dialect is
|
|
466
|
+
itself the kind of false statement this one exists to avoid. Either way the counts are partial, and
|
|
467
|
+
saying so beats presenting a share of a run only partly seen: a crashed worker turned twenty failures
|
|
468
|
+
into a confident `10 of 10`.
|
|
469
|
+
|
|
470
|
+
Every count here is over *tests*, which is why the lines say so: a test that fails and then errors in
|
|
471
|
+
teardown is two red results and one broken test, and the summary is about the second reading.
|
|
472
|
+
|
|
473
|
+
``collect_errors`` counts collection failures, which `--continue-on-collection-errors` lets a run
|
|
474
|
+
carry past. They are red and they are not tests, so they are said on their own line rather than
|
|
475
|
+
folded into a count of tests that would then be larger than the tests there were. Collection is not
|
|
476
|
+
only modules either: a package or a plugin's own collector can fail, so the line says what it counts
|
|
477
|
+
rather than guessing what was being collected.
|
|
478
|
+
"""
|
|
479
|
+
if not found:
|
|
480
|
+
return []
|
|
481
|
+
shown, omitted = found[:_MAX_CLUSTERS], found[_MAX_CLUSTERS:]
|
|
482
|
+
lines = []
|
|
483
|
+
if lost_workers:
|
|
484
|
+
plural = "" if lost_workers == 1 else "s"
|
|
485
|
+
lines.append(f"{lost_workers} worker{plural} died, so these counts cover only what was reported")
|
|
486
|
+
if unreadable_workers:
|
|
487
|
+
plural = "" if unreadable_workers == 1 else "s"
|
|
488
|
+
lines.append(
|
|
489
|
+
f"the failures of {unreadable_workers} worker{plural} could not be read, "
|
|
490
|
+
f"so these counts cover only the rest"
|
|
491
|
+
)
|
|
492
|
+
if collect_errors:
|
|
493
|
+
one = collect_errors == 1
|
|
494
|
+
lines.append(f"{collect_errors} collection error{'' if one else 's'}, not counted below")
|
|
495
|
+
for cluster, where in zip(shown, _headings(shown), strict=True):
|
|
496
|
+
lines.append(_headline(cluster, where, total_failures))
|
|
497
|
+
# a cluster keyed on its diagnostic says the difference in words; the values are what varies inside it
|
|
498
|
+
if cluster.signature.label:
|
|
499
|
+
lines.append(f" {cluster.signature.label}")
|
|
500
|
+
else:
|
|
501
|
+
lines.append(f" actual: {_side(cluster.actuals)}")
|
|
502
|
+
lines.append(f" expected: {_side(cluster.expecteds)}")
|
|
503
|
+
if omitted:
|
|
504
|
+
# said rather than dropped: "five of eleven" silently reads as "these are the causes". "more" rather
|
|
505
|
+
# than "smaller", since equal-sized clusters are ordered by signature
|
|
506
|
+
lines.append(f"{len(omitted)} more cluster{'' if len(omitted) == 1 else 's'} not shown")
|
|
507
|
+
covered = len({nodeid for cluster in found for nodeid in cluster.nodeids})
|
|
508
|
+
if covered < total_failures:
|
|
509
|
+
# what the number measures, not "not clustered": the summary declined to print a pair under the floor
|
|
510
|
+
lines.append(f"{total_failures - covered} of {total_failures} outside any cluster of {minimum}")
|
|
511
|
+
return lines
|