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/__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
+ ]
@@ -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