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
@@ -0,0 +1,1650 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import contextlib
5
+ import json
6
+ import warnings
7
+ from itertools import pairwise
8
+ from typing import TYPE_CHECKING, Any, Final, Protocol, cast
9
+
10
+ import pytest
11
+
12
+ from . import _clustering, _dangling, _inline, _renamed, _satisfies, async_assertions, errors
13
+ from . import snapshot as _snapshot
14
+ from ._engine._compat import BaseExceptionGroup
15
+ from ._engine._diff import _sub_diff_entries
16
+ from ._engine._introspection import laid_out
17
+ from ._engine._path import _ROOT
18
+ from .assertpy import _located, _numbered, _Room
19
+ from .errors import _diff_side, _diff_sides, _json_safe, _render_diff, _safe_repr, _told_apart
20
+ from .exception import _leaves, _nodes
21
+ from .outcome import AssertionOutcome
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Callable, Generator
25
+ from pathlib import Path
26
+
27
+
28
+ class _Stashing(Protocol):
29
+ """The config with what this plugin keeps on it declared, for checkers only.
30
+
31
+ A hook is handed the real `Config` and hangs its own state on it, which is how pytest's own
32
+ plugins do it, and `Config` declares none of those names, nor can it be subclassed to add them:
33
+ it is final. Annotating the parameter and stashing an attribute are therefore not both
34
+ spellable, and the alternative, moving all of this to `config.stash` with typed keys, reaches 101
35
+ sites in the suite besides the plugin's own.
36
+
37
+ A protocol rather than a wider view on purpose: what a hook reaches through it is the stash and
38
+ nothing else. Declared once so the set of names is in a single place rather than spread over the
39
+ hooks that write them. At run time the object is the `Config` pytest passed.
40
+ """
41
+
42
+ _assertpy_profile: str
43
+ _assertpy_allure_mode: str
44
+ _assertpy_dangling_enabled: bool
45
+ _assertpy_dangling_entries: frozenset[str]
46
+ _assertpy_dangling: dict[Path, list[_dangling.Finding]]
47
+ _assertpy_diff_enabled: bool
48
+ _assertpy_diff_max: int
49
+ _assertpy_comparison_enabled: bool
50
+ _assertpy_prev_diff_in_message: bool
51
+ _assertpy_prev_whole_values: bool
52
+ _assertpy_prev_vacuous: bool
53
+ _assertpy_cluster_minimum: int | None
54
+ _assertpy_poll_threshold: float | None
55
+ _assertpy_failed_ids: set[str]
56
+ _assertpy_failure_count: int
57
+ _assertpy_failures: list[tuple[str, list[_clustering.Observation]]]
58
+
59
+
60
+ class _Worker(Protocol):
61
+ """The config as xdist extends it inside a worker process.
62
+
63
+ `workeroutput` is xdist's channel back to the controller. Declared rather than narrowed out of
64
+ the `hasattr` guard that stands in front of every use: narrowing on `hasattr` needs a subclass to
65
+ narrow to, and `Config` is final.
66
+ """
67
+
68
+ workeroutput: dict[str, Any]
69
+
70
+
71
+ try:
72
+ import allure # ty: ignore[unresolved-import] # optional dependency
73
+
74
+ _HAS_ALLURE = True # pragma: no cover - only when allure-pytest is installed
75
+ except ImportError:
76
+ _HAS_ALLURE = False
77
+
78
+ _ALLURE_MODES: Final = frozenset({"off", "diff", "full"})
79
+
80
+
81
+ _FLAGS: Final = (
82
+ "--assertpy-snapshot-update",
83
+ "--assertpy-vacuous",
84
+ "--assertpy-dangling",
85
+ "--assertpy-snapshot-ci",
86
+ "--assertpy-snapshot-no-ci",
87
+ )
88
+ _SETTINGS: Final = (
89
+ "assertpy_profile",
90
+ "assertpy_vacuous",
91
+ "assertpy_allure",
92
+ "assertpy_dangling",
93
+ "assertpy_dangling_entries",
94
+ "assertpy_diff",
95
+ "assertpy_comparison",
96
+ "assertpy_diff_max_entries",
97
+ "assertpy_failure_clusters",
98
+ "assertpy_poll_report",
99
+ )
100
+
101
+
102
+ def pytest_addoption(parser: pytest.Parser) -> None:
103
+ parser.addoption(
104
+ "--assertpy-snapshot-update",
105
+ action="store_true",
106
+ default=False,
107
+ help="Overwrite failing assertpy snapshots with the current values instead of failing",
108
+ )
109
+ parser.addoption(
110
+ "--assertpy-vacuous",
111
+ action="store_true",
112
+ default=False,
113
+ help="Warn when a universal assertion passes over an empty value, having checked nothing",
114
+ )
115
+ parser.addoption(
116
+ "--assertpy-dangling",
117
+ action="store_true",
118
+ default=False,
119
+ help="Warn when assert_that() is used as a statement without asserting anything",
120
+ )
121
+ parser.addoption(
122
+ "--assertpy-snapshot-ci",
123
+ action="store_true",
124
+ default=False,
125
+ help="Fail instead of creating a missing assertpy snapshot (auto-enabled when a CI env is detected)",
126
+ )
127
+ parser.addoption(
128
+ "--assertpy-snapshot-no-ci",
129
+ action="store_true",
130
+ default=False,
131
+ help="Disable CI mode / its autodetection, allowing missing snapshots to be created",
132
+ )
133
+ parser.addini(
134
+ "assertpy_profile",
135
+ help=(
136
+ "Which guards a suite gets without naming them one by one: compatible (default), safe,"
137
+ " strict (safe, and their findings fail the test rather than warn)"
138
+ ),
139
+ default="",
140
+ )
141
+ parser.addini(
142
+ "assertpy_vacuous",
143
+ help="Warn when a universal assertion passes over an empty value: off (default), on",
144
+ default="",
145
+ )
146
+ parser.addini(
147
+ "assertpy_allure",
148
+ help="Allure attachment mode: off, diff (default), full",
149
+ default="",
150
+ )
151
+ parser.addini(
152
+ "assertpy_dangling",
153
+ help="Warn about assert_that() statements that assert nothing: off (default), on",
154
+ default="",
155
+ )
156
+ parser.addini(
157
+ "assertpy_dangling_entries",
158
+ help="Names of your own assert_that wrappers the dangling check should also read as builders",
159
+ type="args",
160
+ default=[],
161
+ )
162
+ parser.addini(
163
+ "assertpy_diff",
164
+ help="Structured diff sections in failure reports: on (default), off",
165
+ default="",
166
+ )
167
+ parser.addini(
168
+ "assertpy_comparison",
169
+ help=(
170
+ "Hand the two values a failed is_equal_to() or has_<name>() compared to pytest_assertrepr_compare"
171
+ " listeners, which is how an IDE's test runner opens its comparison window: on (default), off"
172
+ ),
173
+ default="",
174
+ )
175
+ parser.addini(
176
+ "assertpy_diff_max_entries",
177
+ help="Max diff entries to show (0 = unlimited, default 50)",
178
+ default="",
179
+ )
180
+ parser.addini(
181
+ "assertpy_failure_clusters",
182
+ help=(
183
+ "Group failures sharing one difference: off, or the failing tests a cluster must hold "
184
+ f"(default {_clustering.MINIMUM_SIZE})"
185
+ ),
186
+ default="",
187
+ )
188
+ parser.addini(
189
+ "assertpy_poll_report",
190
+ help="Name polls that converged this close to their deadline: off, or a fraction (default 0.7)",
191
+ default="",
192
+ )
193
+ for flag in _FLAGS:
194
+ parser.addoption(_renamed.old_name(flag), action="store_true", default=False, help=argparse.SUPPRESS)
195
+ for setting in _SETTINGS:
196
+ parser.addini(
197
+ _renamed.old_name(setting),
198
+ help=f"Deprecated name of {setting}, read until assertpy 3.0",
199
+ type="args" if setting == "assertpy_dangling_entries" else None,
200
+ default=[] if setting == "assertpy_dangling_entries" else "",
201
+ )
202
+
203
+
204
+ def _option(config, name: str) -> bool:
205
+ """Whether a flag was given, by its own name or by its assertpy2 one."""
206
+ if config.getoption(name):
207
+ return True
208
+ old = _renamed.old_name(name)
209
+ if not config.getoption(old):
210
+ return False
211
+ _renamed.tell(f"--{old.replace('_', '-')}", f"--{name.replace('_', '-')}")
212
+ return True
213
+
214
+
215
+ def _given(value: object) -> bool:
216
+ """Whether a setting was written: a blank string and an empty list are not."""
217
+ return bool(value.strip()) if isinstance(value, str) else bool(value)
218
+
219
+
220
+ def _ini(config, name: str) -> Any:
221
+ """A setting as the suite wrote it, read by its assertpy2 name where its own is blank.
222
+
223
+ Blank rather than absent: pytest 9 deprecates `config.inicfg`, the one way to ask whether a key was
224
+ written at all, so a current setting written empty does not clear an old one.
225
+ """
226
+ written = config.getini(name)
227
+ old = _renamed.old_name(name)
228
+ before = config.getini(old)
229
+ if not _given(before):
230
+ return written
231
+ _renamed.tell(old, name)
232
+ return written if _given(written) else before
233
+
234
+
235
+ _ALL_ON: Final = {"assertpy_dangling": "on", "assertpy_vacuous": "on"}
236
+
237
+ # What a profile answers for a setting the suite did not name. Both guards are off under `compatible`,
238
+ # since they can turn an inherited suite green run red; `strict` turns them on and fails what they find.
239
+ # Failure clustering has its own default: it costs a passing suite nothing, it reads a run already red
240
+ _PROFILES: Final = {
241
+ "compatible": {"assertpy_dangling": "off", "assertpy_vacuous": "off"},
242
+ "safe": _ALL_ON,
243
+ "strict": _ALL_ON,
244
+ }
245
+
246
+ _ESCALATING: Final = "strict"
247
+ """The profile that turns the two guards' warnings into failures."""
248
+
249
+
250
+ def _profile(config) -> str:
251
+ """Which profile answers, resolved once: an empty setting is the default rather than a mistake.
252
+
253
+ Once, because both settings ask for it independently and a name nobody declared would otherwise
254
+ warn once per question, which reads as two separate mistakes in one config.
255
+ """
256
+ cached = getattr(config, "_assertpy_profile", None)
257
+ if cached is not None:
258
+ return cached
259
+ name = str(_ini(config, "assertpy_profile")).strip().lower()
260
+ if name and name not in _PROFILES:
261
+ warnings.warn(
262
+ f"assertpy_profile={name!r} is not one of ({', '.join(sorted(_PROFILES))}), falling back to 'compatible'",
263
+ stacklevel=1,
264
+ )
265
+ name = ""
266
+ config._assertpy_profile = name or "compatible"
267
+ return config._assertpy_profile
268
+
269
+
270
+ def _written(config, name: str) -> str:
271
+ """What the suite wrote for a setting, empty where it wrote nothing.
272
+
273
+ The two settings a profile answers for are registered with no default of their own, so this tells
274
+ "the suite wrote off" from "the suite said nothing about it", which is the whole of the resolution.
275
+ """
276
+ return str(_ini(config, name)).strip()
277
+
278
+
279
+ def _setting(config, name: str) -> str:
280
+ """What a setting resolves to: what the suite wrote, or what its profile answers for it.
281
+
282
+ Named settings win over the profile, so `safe` plus `assertpy_dangling = off` is a suite that
283
+ wants the other two and has said why in its own config rather than in a comment somewhere.
284
+ """
285
+ return _written(config, name) or _PROFILES[_profile(config)][name]
286
+
287
+
288
+ def _vacuous_guard(config) -> bool:
289
+ """Whether the vacuous-quantifier guard runs, with the three ways of asking it ranked.
290
+
291
+ The flag is the loudest and wins. A written setting comes next, and it has to be able to say `off`:
292
+ the environment variable is how the guard is turned on away from pytest, and a suite that inherited
293
+ one from its CI image could not turn it off in its own config, which is where a reader looks. Where
294
+ the suite said nothing, `safe` turns it on and `compatible` leaves the variable as it found it.
295
+ """
296
+ if _option(config, "assertpy_vacuous"):
297
+ return True
298
+ written = _written(config, "assertpy_vacuous").lower()
299
+ if written in ("on", "off"):
300
+ return written == "on"
301
+ if written:
302
+ # a typo must not read as `off`: this is a guard, and the quiet answer is the dangerous one
303
+ warnings.warn(
304
+ f"assertpy_vacuous={written!r} is not 'on' or 'off', falling back to the profile",
305
+ stacklevel=1,
306
+ )
307
+ return _PROFILES[_profile(config)]["assertpy_vacuous"] == "on" or _satisfies._VACUOUS_GUARD
308
+
309
+
310
+ def _poll_threshold(raw: object) -> float | None:
311
+ """Parse the near-timeout reporting bar: ``off`` silences the report, a fraction moves it.
312
+
313
+ A slow CI box converges late on every poll, where the default bar turns a signal into a line of
314
+ noise per run. ``None`` means the report is off, and collection is skipped with it.
315
+
316
+ A blank value is the default and is not warned about, for the reason `_cluster_minimum` gives.
317
+ """
318
+ written = str(raw).strip()
319
+ if written.lower() == "off":
320
+ return None
321
+ if not written:
322
+ return 0.7
323
+ try:
324
+ value = float(written)
325
+ except ValueError:
326
+ value = 0.0
327
+ if not 0.0 < value <= 1.0:
328
+ warnings.warn(
329
+ f"assertpy_poll_report={written!r} is not 'off' or a fraction in (0, 1], falling back to 0.7",
330
+ stacklevel=1,
331
+ )
332
+ return 0.7
333
+ return value
334
+
335
+
336
+ def _cluster_minimum(raw: object) -> int | None:
337
+ """Parse how many failing tests a cluster must hold: ``off`` silences the summary, a count moves it.
338
+
339
+ ``None`` means the summary is off, and the per-failure recording is skipped with it, so a run that
340
+ does not want the summary pays nothing for it.
341
+
342
+ A count rather than a share of the run, which was tried first and measured strictly worse: under a
343
+ share every additional cause raises the bar for all the others, so a run splitting cleanly into
344
+ five causes of eight printed nothing at all.
345
+
346
+ A blank value is the default and not a mistake, so it is not warned about. It has to be: a warning
347
+ raised here would come out of `pytest_configure`, and under `-W error` a suite carrying an empty
348
+ line for this setting would fail at startup having passed the day before.
349
+ """
350
+ written = str(raw).strip()
351
+ if written.lower() == "off":
352
+ return None
353
+ if not written:
354
+ return _clustering.MINIMUM_SIZE
355
+ try:
356
+ value = int(written)
357
+ except ValueError:
358
+ value = 0
359
+ if value < 2:
360
+ warnings.warn(
361
+ f"assertpy_failure_clusters={written!r} is not 'off' or a count of 2 or more, "
362
+ f"falling back to {_clustering.MINIMUM_SIZE}",
363
+ stacklevel=1,
364
+ )
365
+ return _clustering.MINIMUM_SIZE
366
+ return value
367
+
368
+
369
+ def _dangling_enabled(config) -> bool:
370
+ """Whether the dangling check runs, from the flag or the ini setting.
371
+
372
+ The flag wins: a run that asks for the check on the command line gets it whatever the file says,
373
+ which is how somebody tries it once without editing a config they share with everybody else.
374
+ """
375
+ if _option(config, "assertpy_dangling"):
376
+ return True
377
+ setting = _setting(config, "assertpy_dangling").lower()
378
+ if setting not in {"on", "off"}:
379
+ warnings.warn(
380
+ f"assertpy_dangling={setting!r} is not 'on' or 'off', falling back to 'off'",
381
+ stacklevel=1,
382
+ )
383
+ return False
384
+ return setting == "on"
385
+
386
+
387
+ def _dangling_entries(config) -> frozenset[str]:
388
+ """The project's own wrapper names the dangling check should treat as builder factories.
389
+
390
+ Only a plain name can bind through an import, so anything else is dropped with a warning rather
391
+ than kept to match nothing: `helpers.check` in a config file looks like it works, and a check that
392
+ silently covers none of what it was told to cover is worse than one that is off.
393
+ """
394
+ names = {str(name).strip() for name in _ini(config, "assertpy_dangling_entries") or ()}
395
+ usable = {name for name in names if name.isidentifier()}
396
+ if rejected := sorted(names - usable):
397
+ warnings.warn(
398
+ f"assertpy_dangling_entries: {', '.join(repr(name) for name in rejected)} "
399
+ f"{'is not a plain name' if len(rejected) == 1 else 'are not plain names'} "
400
+ f"and cannot be matched, ignoring",
401
+ stacklevel=1,
402
+ )
403
+ return frozenset(usable)
404
+
405
+
406
+ def _escalate(config) -> None:
407
+ """Under `strict`, make the two guards' warnings fail the test that raised one.
408
+
409
+ Scoped to this library's own two categories rather than set as a blanket `filterwarnings = error`,
410
+ which would change the meaning of every `DeprecationWarning` a suite's other dependencies raise.
411
+
412
+ Both filters go in whether or not their guard is on, because a guard that is off raises nothing for
413
+ the filter to catch. Asking first read as a safeguard and was measured to change nothing.
414
+
415
+ Failure clustering is not here and cannot be: it reads a run that already went red.
416
+ """
417
+ if _profile(config) != _ESCALATING:
418
+ return
419
+ for category in (errors.DanglingAssertionWarning, errors.VacuousAssertionWarning):
420
+ config.addinivalue_line("filterwarnings", f"error::{category.__module__}.{category.__qualname__}")
421
+
422
+
423
+ def pytest_configure(config: pytest.Config) -> None:
424
+ stashed = cast("_Stashing", config)
425
+ mode = str(_ini(config, "assertpy_allure")).strip() or "diff"
426
+ if mode not in _ALLURE_MODES:
427
+ warnings.warn(
428
+ f"assertpy_allure={mode!r} is not a valid mode "
429
+ f"({', '.join(sorted(_ALLURE_MODES))}), falling back to 'diff'",
430
+ stacklevel=1,
431
+ )
432
+ stashed._assertpy_allure_mode = "diff"
433
+ else:
434
+ stashed._assertpy_allure_mode = mode
435
+ stashed._assertpy_dangling_enabled = _dangling_enabled(config)
436
+ stashed._assertpy_dangling_entries = _dangling_entries(config)
437
+ stashed._assertpy_diff_enabled = _ini(config, "assertpy_diff") != "off"
438
+ stashed._assertpy_comparison_enabled = _ini(config, "assertpy_comparison") != "off"
439
+ try:
440
+ stashed._assertpy_diff_max = int(_ini(config, "assertpy_diff_max_entries"))
441
+ except (ValueError, TypeError):
442
+ stashed._assertpy_diff_max = 50
443
+ # the plugin hangs the diff on the failure itself; the prior value is restored, not forced, so hooks stay balanced
444
+ stashed._assertpy_prev_diff_in_message = errors._RENDER_DIFF_IN_MESSAGE
445
+ errors._RENDER_DIFF_IN_MESSAGE = False
446
+ # at -vv pytest stops shortening, and so does a message: what the cap left out is whole there
447
+ stashed._assertpy_prev_whole_values = errors._WHOLE_VALUES
448
+ errors._WHOLE_VALUES = int(config.getoption("verbose") or 0) >= 2
449
+ stashed._assertpy_cluster_minimum = _cluster_minimum(_ini(config, "assertpy_failure_clusters"))
450
+ _session_config[0] = config
451
+ stashed._assertpy_failures = []
452
+ stashed._assertpy_failure_count = 0
453
+ stashed._assertpy_poll_threshold = _poll_threshold(_ini(config, "assertpy_poll_report"))
454
+ # nothing reads the samples once the report is off, so stop paying for them at the poll site
455
+ async_assertions._COLLECT_RETRIES = stashed._assertpy_poll_threshold is not None
456
+ # save and restore rather than force back: the environment variable may have turned the guard on before import
457
+ stashed._assertpy_prev_vacuous = _satisfies._VACUOUS_GUARD
458
+ _satisfies._VACUOUS_GUARD = _vacuous_guard(config)
459
+ _escalate(config)
460
+ if _option(config, "assertpy_snapshot_update"):
461
+ _snapshot._UPDATE_ALL = True
462
+ if _option(config, "assertpy_snapshot_ci"):
463
+ _snapshot._CI_MODE = True
464
+ elif _option(config, "assertpy_snapshot_no_ci"):
465
+ _snapshot._CI_MODE = False
466
+
467
+
468
+ def pytest_unconfigure(config: pytest.Config) -> None:
469
+ errors._RENDER_DIFF_IN_MESSAGE = getattr(config, "_assertpy_prev_diff_in_message", True)
470
+ errors._WHOLE_VALUES = getattr(config, "_assertpy_prev_whole_values", False)
471
+ async_assertions._COLLECT_RETRIES = False
472
+ async_assertions._RETRIES.clear()
473
+ # the report reads this at the end of a session, so a second one in the same process reported the first
474
+ _retried.clear()
475
+ # module-level, so a second session would open with the first one's failures, consumed by a hook that may not run
476
+ _controller_failures.clear()
477
+ _controller_failure_count[0] = 0
478
+ _controller_lost_workers[0] = 0
479
+ _controller_unreadable_workers[0] = 0
480
+ _controller_collect_errors[0] = 0
481
+ _session_config[0] = None
482
+ previous = getattr(config, "_assertpy_prev_vacuous", None)
483
+ _satisfies._VACUOUS_GUARD = _satisfies._env_enabled() if previous is None else previous
484
+ if _option(config, "assertpy_snapshot_update"):
485
+ _snapshot._UPDATE_ALL = False
486
+ if _option(config, "assertpy_snapshot_ci") or _option(config, "assertpy_snapshot_no_ci"):
487
+ _snapshot._CI_MODE = None
488
+
489
+
490
+ # snapshots touched by xdist workers, collected on the controller as each worker finishes
491
+ _controller_touched: set = set()
492
+
493
+ # workers must not rewrite shared source files in parallel
494
+ _controller_inline: list = []
495
+
496
+ # node ids that reached each snapshot key, shipped by xdist workers and unioned on the controller
497
+ _controller_accesses: dict = {}
498
+
499
+ # the summary is written on the controller, and a worker's own config is not the controller's
500
+ _controller_failures: list = []
501
+ _controller_failure_count: list = [0]
502
+ _controller_lost_workers: list = [0]
503
+ _controller_unreadable_workers: list = [0]
504
+ _controller_collect_errors: list = [0]
505
+
506
+ # the session's config, for the one hook that is handed a report and no way back to it
507
+ _session_config: list = [None]
508
+
509
+
510
+ def pytest_collection_modifyitems(session: pytest.Session, config: pytest.Config, items: list[pytest.Item]) -> None:
511
+ """Read every collected test file once and record the statements that assert nothing.
512
+
513
+ Collection rather than runtime: the check is static (see `_dangling`), so a selected subset
514
+ (`-k`, `--lf`) is the subset that gets read, and each path is read once however many items it
515
+ contributed. The findings are only *recorded* here; they are reported from `runtest_setup`,
516
+ because a warning raised out of a collection hook under `-W error` aborts pytest with an
517
+ INTERNALERROR instead of failing anything a reader can act on.
518
+ """
519
+ stashed = cast("_Stashing", config)
520
+ stashed._assertpy_dangling = {}
521
+ if not getattr(config, "_assertpy_dangling_enabled", False):
522
+ return
523
+ for item in items:
524
+ path = getattr(item, "path", None)
525
+ if path is None or path in stashed._assertpy_dangling: # pragma: no cover - items carry a path
526
+ continue
527
+ try:
528
+ source = _dangling.read_module(path)
529
+ entries = getattr(config, "_assertpy_dangling_entries", frozenset())
530
+ stashed._assertpy_dangling[path] = _dangling.findings(source, str(path), entries)
531
+ except (OSError, SyntaxError): # pragma: no cover - pytest imported the module, so it read and parsed
532
+ stashed._assertpy_dangling[path] = []
533
+
534
+
535
+ def _item_scope(item) -> tuple[str, ...]:
536
+ """The test's own scope chain, matching what the static pass recorded: ``("TestOne", "test_same")``.
537
+
538
+ Read from `__qualname__` rather than assembled from `item.cls`, which names only the innermost
539
+ class: a method of a class nested in another class is `TestOuter.TestInner.test_it` to the source
540
+ and only `TestInner.test_it` to that attribute, and the two chains then failed to line up.
541
+
542
+ A function name alone is worse than either: two classes in one file may each define `test_same`,
543
+ and a finding matched by name went to whichever ran first.
544
+ """
545
+ qualname = getattr(getattr(item, "function", None), "__qualname__", None)
546
+ if not qualname:
547
+ return ()
548
+ return tuple(part for part in qualname.split(".") if part != "<locals>")
549
+
550
+
551
+ def _report_dangling(item):
552
+ """Warn once per file, on the first test to run from it, so pytest attributes it to that node.
553
+
554
+ `warn_explicit` rather than `warn`: the location that matters is the offending statement, not
555
+ this line, and a reader jumping to the warning should land in their own test file.
556
+ """
557
+ config = getattr(item, "config", None) # a hook driven directly in a test carries no config
558
+ recorded = getattr(config, "_assertpy_dangling", {})
559
+ found = recorded.get(getattr(item, "path", None))
560
+ if not found:
561
+ return
562
+ here = _item_scope(item)
563
+ # a finding outside any def has no test to attach to, so the first item takes it
564
+ mine = [one for one in found if not one.scope or (here and one.scope[: len(here)] == here)]
565
+ if not mine:
566
+ return
567
+ recorded[item.path] = [one for one in recorded[item.path] if one not in mine]
568
+ # one warning per test: under `-W error` the first leaves this function, so a second is never reported
569
+ first, rest = mine[0], mine[1:]
570
+ message = first.message
571
+ if rest:
572
+ lines = ", ".join(str(one.lineno) for one in rest)
573
+ message = f"{message} (and {len(rest)} more in this test, at line{'' if len(rest) == 1 else 's'} {lines})"
574
+ warnings.warn_explicit(message, errors.DanglingAssertionWarning, first.path, first.lineno)
575
+
576
+
577
+ def pytest_runtest_setup(item: pytest.Item) -> None:
578
+ """Name the running test, so a snapshot key reached by two of them can be told from a helper that
579
+ snapshots twice inside one."""
580
+ _snapshot._CURRENT_NODE = item.nodeid
581
+ _report_dangling(item)
582
+
583
+
584
+ def pytest_collectreport(report: pytest.CollectReport) -> None:
585
+ """Note a collection that failed, which is red and never reaches a test report.
586
+
587
+ `--continue-on-collection-errors` runs the rest of the suite, so pytest ends with `3 failed, 1 error`
588
+ while the summary saw only the three and said `3 of 3`. Counted on its own line rather than into the
589
+ denominator, because a collector is not a test and `3 of 4 failing tests` would name a fourth test
590
+ that does not exist. Without that flag the run stops and no summary is written, so this costs nothing in
591
+ the ordinary case.
592
+
593
+ The report arrives without its config, and this hook is the only place a collection failure is
594
+ visible, which is what the session-level reference is for.
595
+ """
596
+ config = _session_config[0]
597
+ if config is None or not report.failed or hasattr(config, "workeroutput"):
598
+ # not on a worker: each collects the whole suite, so counting there made one broken module one result per worker
599
+ return
600
+ if getattr(config, "_assertpy_cluster_minimum", None) is not None:
601
+ _controller_collect_errors[0] += 1
602
+
603
+
604
+ @pytest.hookimpl(optionalhook=True) # xdist-provided hook: silently ignored when xdist is not installed
605
+ def pytest_testnodedown(node: Any, error: object) -> None:
606
+ """xdist controller hook: collect the touched snapshots and inline edits each worker shipped.
607
+
608
+ ``error`` is set when the worker died rather than finished. Then it never ran its `sessionfinish`,
609
+ so whatever it had recorded never left it, and the cluster summary would otherwise report a share
610
+ of the failures it happened to receive as though that were the whole run.
611
+ """
612
+ if error is not None:
613
+ _controller_lost_workers[0] += 1
614
+ _collect_worker_failures(node, died=error is not None)
615
+ touched = getattr(node, "workeroutput", {}).get("assertpy_touched")
616
+ if touched:
617
+ _controller_touched.update(tuple(item) for item in touched)
618
+ retried = getattr(node, "workeroutput", {}).get("assertpy_retried")
619
+ if retried:
620
+ _retried.extend(tuple(row) for row in retried)
621
+ inline = getattr(node, "workeroutput", {}).get("assertpy_inline")
622
+ if inline:
623
+ _controller_inline.extend(tuple(record) for record in inline)
624
+ accesses = getattr(node, "workeroutput", {}).get("assertpy_accesses")
625
+ if accesses:
626
+ # unioned here: two parametrised cases on two workers are one node id each locally
627
+ for snapname, key, nodes, site in accesses:
628
+ _controller_accesses.setdefault((snapname, key), set()).update(nodes)
629
+ _snapshot._ACCESS_SITES.setdefault((snapname, key), site)
630
+
631
+
632
+ def _is_full_run(config) -> bool:
633
+ """Whether the run selected all tests (no ``-k`` / ``-m`` / ``--lf`` / ``--ff`` and no nodeid
634
+ selection). Orphan detection is only reliable on a full run, since a deselected or nodeid-selected
635
+ live test would otherwise look obsolete."""
636
+ opt = config.option
637
+ if any("::" in str(arg) for arg in getattr(opt, "file_or_dir", None) or ()):
638
+ return False # a nodeid selection (path::test) runs only a subset of a file's tests
639
+ return not (
640
+ getattr(opt, "keyword", "")
641
+ or getattr(opt, "markexpr", "")
642
+ or getattr(opt, "last_failed", False)
643
+ or getattr(opt, "failed_first", False)
644
+ )
645
+
646
+
647
+ _retried: list[tuple[str, int, float, float]] = []
648
+
649
+
650
+ def _drain_retries(nodeid: str) -> None:
651
+ """Move any retried polls this test performed onto the run-level list, tagged with the test."""
652
+ for attempts, elapsed, budget in async_assertions._RETRIES:
653
+ _retried.append((nodeid, attempts, elapsed, budget))
654
+ async_assertions._RETRIES.clear()
655
+
656
+
657
+ def _report_retries(config) -> None:
658
+ """Name the polls that converged against their deadline: that run passed, the next one may not.
659
+
660
+ Retrying is what ``eventually()`` is for, so a retry on its own says nothing. Spending most of the
661
+ budget before converging is what turns into a failure on a slower machine.
662
+ """
663
+ threshold = getattr(config, "_assertpy_poll_threshold", 0.7)
664
+ if threshold is None:
665
+ return
666
+ reporter = config.pluginmanager.get_plugin("terminalreporter")
667
+ if reporter is None: # pragma: no cover - the terminal reporter is always present under pytest
668
+ return
669
+ late = [row for row in _retried if row[3] and row[2] / row[3] >= threshold]
670
+ if not late:
671
+ return
672
+ reporter.write_line("")
673
+ reporter.write_line("assertpy polls that nearly timed out:")
674
+ for nodeid, attempts, elapsed, budget in late:
675
+ reporter.write_line(
676
+ f" {nodeid}: converged on attempt {attempts} at {elapsed:.2f}s of {budget:.1f}s"
677
+ f" ({elapsed / budget:.0%} of the budget)"
678
+ )
679
+
680
+
681
+ def _warn_on_reused_snapshot_keys(session) -> None:
682
+ """Report every reused snapshot key, with the number of tests that reached it.
683
+
684
+ `assertpy.snapshot._record_access()` raises the warning that is worth reading, from inside the
685
+ assertion: pytest attributes it to a nodeid, puts it in the normal summary, and under ``-W error``
686
+ fails that test rather than this hook. What it cannot do is count. It fires when the second test
687
+ arrives, with more still to come, and it never sees the other xdist workers - parametrised cases
688
+ split across them are one access each, so no worker sees a second reach and only the union does.
689
+
690
+ The count therefore lives here, where every access has been collected, and this runs for every
691
+ reused key rather than only for the ones no worker reported. Two messages about one key is the
692
+ price: the first says where it happened, this one says how big it is.
693
+ """
694
+ totals: dict = {key: set(nodes) for key, nodes in _snapshot._ACCESS_NODES.items()}
695
+ for key, nodes in _controller_accesses.items():
696
+ totals.setdefault(key, set()).update(nodes)
697
+ _snapshot._ACCESS_NODES.clear()
698
+ _controller_accesses.clear()
699
+ _snapshot._WARNED.clear()
700
+ for (snapname, key), nodes in sorted(totals.items()):
701
+ if len(nodes) < 2:
702
+ continue
703
+ message = _snapshot._reuse_message(snapname, key, _snapshot._ACCESS_SITES.get((snapname, key), ""), len(nodes))
704
+ try:
705
+ warnings.warn(message, _snapshot.SnapshotKeyReusedWarning, stacklevel=1)
706
+ except _snapshot.SnapshotKeyReusedWarning:
707
+ _fail_the_run(session, message)
708
+ _snapshot._ACCESS_SITES.clear()
709
+
710
+
711
+ def _fail_the_run(session, message: str) -> None:
712
+ """Turn a problem found after the last test into a failed run instead of a broken hook.
713
+
714
+ An escalated sweep warning, or an inline snapshot that could not be written. An ``error`` entry in
715
+ the warning filters raises here rather than at a test, and an exception out of a session-finish hook
716
+ is an INTERNALERROR with this module's traceback in it - which buries the message the reader needs
717
+ under a stack that points at the wrong place. Print it and go red.
718
+ """
719
+ # only a run that otherwise succeeded: reached from a `finally`, so Ctrl-C and an internal error land here too
720
+ if session.exitstatus == pytest.ExitCode.OK:
721
+ session.exitstatus = pytest.ExitCode.TESTS_FAILED
722
+ reporter = session.config.pluginmanager.get_plugin("terminalreporter")
723
+ if reporter is None: # pragma: no cover - the terminal reporter is always present under pytest
724
+ return
725
+ reporter.write_line("")
726
+ reporter.write_line(f"ERROR: {message}", red=True)
727
+
728
+
729
+ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
730
+ config = session.config
731
+ if hasattr(config, "workeroutput"): # xdist worker: ship recorded work to the controller, defer the rest
732
+ worker = cast("_Worker", config)
733
+ worker.workeroutput["assertpy_touched"] = [list(item) for item in _snapshot._TOUCHED]
734
+ worker.workeroutput["assertpy_inline"] = _inline.shipped()
735
+ worker.workeroutput["assertpy_retried"] = [list(row) for row in _retried]
736
+ worker.workeroutput["assertpy_failures"] = [
737
+ [nodeid, [_observation_to_wire(one) for one in found]]
738
+ for nodeid, found in getattr(config, "_assertpy_failures", [])
739
+ ]
740
+ worker.workeroutput["assertpy_failure_count"] = getattr(config, "_assertpy_failure_count", 0)
741
+ worker.workeroutput["assertpy_accesses"] = [
742
+ [snapname, key, sorted(nodes), _snapshot._ACCESS_SITES.get((snapname, key), "")]
743
+ for (snapname, key), nodes in _snapshot._ACCESS_NODES.items()
744
+ ]
745
+ return
746
+ # controller / single process: apply inline edits (workers' plus any recorded here) into source
747
+ _report_retries(config)
748
+ _warn_on_reused_snapshot_keys(session)
749
+ rows = [*_inline.shipped(), *_controller_inline]
750
+ _controller_inline.clear()
751
+ for problem in _inline.apply_inline_records(rows, str(config.rootpath)):
752
+ _fail_the_run(session, problem)
753
+ touched = set(_snapshot._TOUCHED) | _controller_touched
754
+ _controller_touched.clear()
755
+ if not touched or not _is_full_run(config):
756
+ # on a subset run the touched set is incomplete, so pruning would delete a live sibling
757
+ return
758
+ sub_orphans, whole_orphans = _snapshot._find_orphans(touched)
759
+ if not sub_orphans and not whole_orphans:
760
+ return
761
+ pruned = []
762
+ # prune obsolete sub-snaps only under update mode; whole files are always report-only
763
+ if sub_orphans and _snapshot._update_enabled():
764
+ _snapshot._prune_sub_key_orphans(sub_orphans)
765
+ pruned, sub_orphans = sub_orphans, []
766
+ _report_snapshot_orphans(config, sub_orphans, whole_orphans, pruned)
767
+
768
+
769
+ def _refuse(value: object) -> bool:
770
+ """Raise, from inside a comprehension's condition, where a plain `raise` cannot go."""
771
+ raise TypeError(value)
772
+
773
+
774
+ def _collect_worker_failures(node, died: bool = False) -> None:
775
+ """Take one worker's recorded failures, or leave the worker counted as lost.
776
+
777
+ Everything here came off the wire, and a controller running one version of this library against a
778
+ worker running another is a real installation. A row that does not unpack must not take down a hook
779
+ that pytest answers with INTERNALERROR, and it must not be silently dropped either: the summary
780
+ reports what share of the run it explains, so a worker whose failures went missing is the same
781
+ situation as a worker that died.
782
+
783
+ Unreadable is counted apart from dead (``died``), because the two are different claims about the run
784
+ and a worker that finished must not be reported as having crashed. A worker that already counted as
785
+ dead is not looked at here at all: it has one thing wrong with it, not two.
786
+
787
+ Both wire values are read outright rather than defaulted, which is what makes a worker that shipped
788
+ neither unreadable rather than invisible. This version ships both from every worker, summary on or
789
+ off, so their absence is a worker that does not speak this protocol, and its failures are missing from
790
+ the denominator: leaving that unsaid is how a run of twelve prints a confident `6 of 6`.
791
+
792
+ Node ids are prefixed with the worker's own name because two workers can legitimately report the
793
+ same test: `--dist=each` runs the whole suite on every worker. Without the prefix the denominator
794
+ counted both executions and the cluster counted one, and the summary reported a run half unexplained
795
+ when nothing was unexplained at all.
796
+ """
797
+ if died: # already counted, and a worker that never finished has nothing to have shipped
798
+ return
799
+ output = getattr(node, "workeroutput", {})
800
+ worker = getattr(node, "gateway", None)
801
+ prefix = getattr(worker, "id", "") or ""
802
+ try:
803
+ received = [
804
+ (f"{prefix}::{nodeid}", [_observation_from_wire(one) for one in found])
805
+ for nodeid, found in output["assertpy_failures"]
806
+ if isinstance(nodeid, str) or _refuse(nodeid)
807
+ ]
808
+ counted = output["assertpy_failure_count"]
809
+ # both halves or neither, checked rather than coerced, since `int()` would take `True` and `3.9`
810
+ if isinstance(counted, bool) or not isinstance(counted, int):
811
+ raise TypeError(counted)
812
+ if counted < len({nodeid for nodeid, _ in received}):
813
+ raise ValueError(counted)
814
+ except Exception: # pragma: no cover - the guard itself; a malformed payload is tested through it
815
+ _controller_unreadable_workers[0] += 1
816
+ return
817
+ _controller_failures.extend(received)
818
+ _controller_failure_count[0] += counted
819
+
820
+
821
+ def _observation_to_wire(one):
822
+ """An observation as plain lists, which is all execnet can carry between worker and controller."""
823
+ key = one.signature
824
+ steps = [list(step) for step in key.steps]
825
+ return [key.located, key.where, steps, key.label, list(key.values), one.actual, one.expected]
826
+
827
+
828
+ def _observation_from_wire(row):
829
+ """Rebuild an observation the controller can group by, refusing anything it could not group by.
830
+
831
+ Tuples again, not lists: the signature *is* the cluster key, so it has to hash, and a list inside it
832
+ would raise the moment the controller tried to group anything a worker sent.
833
+
834
+ Each field is checked rather than trusted, because a row of the right length can still hold the wrong
835
+ thing. A list where a value belongs unpacks happily and then raises out of the grouping, past the
836
+ point where the worker could be reported as unreadable: the summary simply vanished, and the run said
837
+ nothing about why.
838
+ """
839
+ located, where, steps, label, values, actual, expected = row
840
+ if not (
841
+ isinstance(located, bool)
842
+ and all(isinstance(field, str) for field in (where, label, actual, expected))
843
+ and all(isinstance(one, str) for one in values)
844
+ and all(len(step) == 2 and all(isinstance(part, str) for part in step) for step in steps)
845
+ ):
846
+ raise TypeError(row)
847
+ key = _clustering.Signature(located, where, tuple(tuple(step) for step in steps), label, tuple(values))
848
+ if not _clustering.is_well_formed(key):
849
+ raise ValueError(key)
850
+ return _clustering.Observation(key, actual, expected)
851
+
852
+
853
+ def _record_for_clustering(config, nodeid, exc):
854
+ """Note what one failure differed at, for the end-of-run summary.
855
+
856
+ Counted even when it carries no diff, and even when there is no exception to ask - a strict xfail
857
+ that passed is red and holds nothing - because the summary reports how much of the run it accounts
858
+ for, and that is only meaningful against every failure.
859
+
860
+ The failure's own diagnostic line keys the differences that have no location, so it is asked for
861
+ here, while the values are still at hand.
862
+ """
863
+ if getattr(config, "_assertpy_cluster_minimum", None) is None:
864
+ return
865
+ # node ids, not a counter: a retried test is reported failing once per attempt
866
+ failed = getattr(config, "_assertpy_failed_ids", None)
867
+ if failed is None:
868
+ failed = config._assertpy_failed_ids = set()
869
+ failed.add(nodeid)
870
+ config._assertpy_failure_count = len(failed)
871
+ try:
872
+ # inside the guard: `diff` is our name on somebody else's exception, and one that raised took the run down
873
+ diff = getattr(exc, "diff", None)
874
+ # the hint the failure already computed; `_outcome` is absent on a failure raised outside the fluent path
875
+ outcome = getattr(exc, "_outcome", None)
876
+ label = getattr(outcome, "hint", None)
877
+ found = _clustering.observations_of(diff, label)
878
+ except Exception:
879
+ # a report hook is no place to raise from: pytest answers with INTERNALERROR and the run's results are lost
880
+ return
881
+ if found:
882
+ config._assertpy_failures.append((nodeid, found))
883
+
884
+
885
+ def pytest_terminal_summary(terminalreporter: Any, exitstatus: int, config: pytest.Config) -> None:
886
+ """Say in a line what the failures had in common, when enough of them had anything in common.
887
+
888
+ This hook rather than `pytest_sessionfinish`, which runs before the tracebacks are printed and puts
889
+ the summary above all of them. Here it lands in the closing block, immediately above
890
+ `short test summary info`.
891
+
892
+ Not below it, and that is not a choice: the terminal reporter writes that list from the `finally`
893
+ of its own wrapper around this hook, so every third-party implementation runs first. Measured with
894
+ a plain implementation, with `trylast`, and with a wrapper printing after its yield - all three
895
+ land in the same place.
896
+ """
897
+ try:
898
+ _write_cluster_summary(terminalreporter, config)
899
+ except Exception as exc: # pragma: no cover - the barrier itself; its parts are tested directly
900
+ # `Exception`, not `BaseException`: a Ctrl-C or a SystemExit through here still has to travel
901
+ with contextlib.suppress(Exception):
902
+ # the notice is suppressed too: under `-W error` it would replace the failure with its own traceback
903
+ warnings.warn(f"assertpy could not write its failure-cluster summary: {exc!r}", stacklevel=1)
904
+
905
+
906
+ def _write_cluster_summary(terminalreporter, config) -> None:
907
+ """Build and print the summary, or return having printed nothing."""
908
+ minimum = getattr(config, "_assertpy_cluster_minimum", None)
909
+ # a worker's failures arrive through `pytest_testnodedown`, and the controller runs none of its own
910
+ total = getattr(config, "_assertpy_failure_count", 0) + _controller_failure_count[0]
911
+ recorded = [*getattr(config, "_assertpy_failures", []), *_controller_failures]
912
+ if minimum is None or total < minimum:
913
+ return
914
+ found = _clustering.clusters(recorded, total, minimum=minimum)
915
+ lines = _clustering.render(
916
+ found,
917
+ total,
918
+ _controller_lost_workers[0],
919
+ _controller_unreadable_workers[0],
920
+ minimum=minimum,
921
+ collect_errors=_controller_collect_errors[0],
922
+ )
923
+ if not lines:
924
+ return
925
+ terminalreporter.write_line("")
926
+ terminalreporter.write_line("assertpy failure clusters:")
927
+ for line in lines:
928
+ terminalreporter.write_line(f" {line}")
929
+
930
+
931
+ def _report_snapshot_orphans(config, sub_orphans, whole_orphans, pruned):
932
+ reporter = config.pluginmanager.get_plugin("terminalreporter")
933
+ if reporter is None: # pragma: no cover - the terminal reporter is always present under pytest
934
+ return
935
+ lines = [f"removed obsolete snapshot: {snap}::{key}" for snap, key in pruned]
936
+ lines += [
937
+ f"obsolete snapshot (run --assertpy-snapshot-update on a full run to remove): {snap}::{key}"
938
+ for snap, key in sub_orphans
939
+ ]
940
+ lines += [f"obsolete snapshot file (delete manually if its test is gone): {snap}" for snap in whole_orphans]
941
+ reporter.write_line("")
942
+ reporter.write_line("assertpy snapshots:")
943
+ for line in lines:
944
+ reporter.write_line(f" {line}")
945
+
946
+
947
+ @pytest.hookimpl(hookwrapper=True)
948
+ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[Any]) -> Generator[None, Any, None]:
949
+ outcome = yield
950
+ report = outcome.get_result()
951
+
952
+ # every phase, not just "call": a fixture polling in teardown would tag its retries with the next test
953
+ _drain_retries(report.nodeid)
954
+ if not report.failed:
955
+ return
956
+
957
+ # ahead of the gates and without needing an exception: it counts an erroring fixture and a strict xpass too
958
+ exc = call.excinfo.value if call.excinfo is not None else None
959
+ _record_for_clustering(item.config, report.nodeid, exc)
960
+
961
+ if exc is None:
962
+ return
963
+
964
+ # pytest groups two failing finalizers, and numbering follows the order the traceback prints them in
965
+ leaves = _leaves(exc)
966
+ room, attachments = _Room(), _Room()
967
+ for position, leaf in enumerate(leaves, 1):
968
+ if isinstance(leaf, AssertionError):
969
+ suffix = f" ({position} of {len(leaves)})" if len(leaves) > 1 else ""
970
+ # two barriers: a `diff` property that raised took the run down, and one refusal must not cost the other
971
+ with contextlib.suppress(Exception):
972
+ _attach_report_sections(item, report, leaf, suffix=suffix)
973
+ with contextlib.suppress(Exception):
974
+ _attach_to_allure(item, report, leaf)
975
+ # any leaf: an exception of the caller's that escaped a soft block carries what the block had collected
976
+ within = f"leaf {position} of {len(leaves)}, " if len(leaves) > 1 else ""
977
+ _attach_collected(item, report, leaf, room, attachments, within)
978
+ # and so does a group of the caller's, which no leaf is
979
+ carriers = [node for node in _nodes(exc) if isinstance(node, BaseExceptionGroup) and _carries(node)]
980
+ for position, node in enumerate(carriers, 1):
981
+ within = "group, " if len(carriers) == 1 else f"group {position} of {len(carriers)}, "
982
+ _attach_collected(item, report, node, room, attachments, within)
983
+
984
+ # one pair is all a listener keeps, so a group of failures hands none
985
+ if len(leaves) == 1 and getattr(item.config, "_assertpy_comparison_enabled", True):
986
+ # the record is on an exception of anyone's and a listener is another plugin's code: neither costs the report
987
+ with contextlib.suppress(Exception):
988
+ compared = _compared_pair(leaves[0])
989
+ if compared is not None:
990
+ left, right = _for_a_listener(compared)
991
+ item.config.hook.pytest_assertrepr_compare(config=item.config, op="==", left=left, right=right)
992
+
993
+
994
+ def _compared_pair(exc: BaseException) -> tuple[object, object] | None:
995
+ """The two values a failed equality held against each other, or ``None`` for any other failure.
996
+
997
+ `pytest_assertrepr_compare` is the hook pytest calls for a failed ``assert left == right``, and a test runner
998
+ that shows the two sides in a comparison window listens to it: PyCharm's does. It gave assertpy's failures
999
+ that window by reading their message, which a failure of this class no longer matches.
1000
+
1001
+ The pair is on the failure because the assertion that failed put it there, and only an equality does:
1002
+ ``is_equal_to()`` and a dynamic ``has_<name>()``. It is the pair ``==`` alone decided. Under ``ignore=``
1003
+ or ``include=`` that is the two copies without the keys left out, and under ``tolerance=``,
1004
+ ``comparators=``, ``strict_types=`` or ``ignore_null=`` there is none. A negation, a membership and a
1005
+ block of several failures hold no pair that was compared. A listener compares two texts, as for a plain
1006
+ ``assert``: ``1`` beside ``True`` differs there, two NaNs do not.
1007
+
1008
+ A listener hears it once this hook's wrapper has resumed, which is before any report is logged: one that
1009
+ reads the pair in a report wrapper of its own that resumes earlier does not.
1010
+ """
1011
+ if not isinstance(exc, errors.AssertionFailure):
1012
+ # anything may sit under the name on another exception
1013
+ return None
1014
+ try:
1015
+ pair = exc._compared
1016
+ except Exception: # a subclass may answer the name with code of its own, which must not cost the report
1017
+ return None
1018
+ return pair if type(pair) is tuple and len(pair) == 2 else None
1019
+
1020
+
1021
+ def _for_a_listener(pair: tuple[object, object]) -> tuple[object, object]:
1022
+ """The pair as a listener can lay it out: a record taken apart under ``ignore=`` or ``include=`` is handed as
1023
+ the dict of its fields.
1024
+
1025
+ A listener prints each side with `pprint`, which breaks a dict into lines and prints a class it does not know
1026
+ on one, whatever its length, so a record read `Row(...)` on a single line beside a dict sorted by key. Under
1027
+ those options the two were compared by their fields. A pair too deep to walk is handed as it is.
1028
+ """
1029
+ try:
1030
+ return laid_out(pair[0]), laid_out(pair[1])
1031
+ except Exception:
1032
+ return pair
1033
+
1034
+
1035
+ def _named(exc) -> tuple[bool, bool]:
1036
+ """Which sides a failure named, read from its record where it has one.
1037
+
1038
+ `expected` alone cannot tell "compared against None" from "no expected value". A failure built by
1039
+ hand, by `eventually()` or by a snapshot re-wrap has no record, and its values are all there is.
1040
+ """
1041
+ outcome = getattr(exc, "_outcome", None)
1042
+ if outcome is None:
1043
+ return getattr(exc, "actual", None) is not None, getattr(exc, "expected", None) is not None
1044
+ return outcome.actual_provided, outcome.has_expected
1045
+
1046
+
1047
+ _COLORED: Final = "_assertpy_colored"
1048
+ """The report attribute holding each diff section's colored text, which xdist carries like any attribute."""
1049
+
1050
+
1051
+ def pytest_runtest_logreport(report: pytest.TestReport) -> None:
1052
+ # every report passes here, on a worker and again on the controller, which rebuilds the representation
1053
+ colored = vars(report).get(_COLORED)
1054
+ if colored and callable(getattr(report.longrepr, "addsection", None)):
1055
+ _color_when_drawn(report.longrepr, colored)
1056
+
1057
+
1058
+ def _color_when_drawn(longrepr: Any, colored: dict[str, str]) -> None:
1059
+ longrepr.toterminal = _ColoredDrawing(longrepr, colored)
1060
+
1061
+
1062
+ class _ColoredDrawing:
1063
+ """A representation's `toterminal`: the colored diff to a writer that takes color, the plain one to every other.
1064
+
1065
+ A section is one string every reader prints as it is, so color written into it reached a JUnit body
1066
+ as escape codes. pytest colors its own traceback as it draws it, and this does the same for ours:
1067
+ the sections change only for the length of one drawing, with no other code running in between.
1068
+
1069
+ A class rather than a closure, because a closure made the representation refuse to pickle and kept a
1070
+ deep copy drawing the original.
1071
+ """
1072
+
1073
+ def __init__(self, longrepr: Any, colored: dict[str, str]) -> None:
1074
+ self.longrepr = longrepr
1075
+ self.colored = colored
1076
+
1077
+ def __call__(self, tw: Any) -> None:
1078
+ longrepr = self.longrepr
1079
+ draw = type(longrepr).toterminal
1080
+ if not getattr(tw, "hasmarkup", False):
1081
+ draw(longrepr, tw)
1082
+ return
1083
+ plain = longrepr.sections
1084
+ longrepr.sections = [(name, self.colored.get(name, content), *rest) for name, content, *rest in plain]
1085
+ try:
1086
+ draw(longrepr, tw)
1087
+ finally:
1088
+ longrepr.sections = plain
1089
+
1090
+
1091
+ def _add_section(report: pytest.TestReport, name: str, content: str) -> None:
1092
+ """Hang *content* on the failure's own representation, which every reader of the report renders.
1093
+
1094
+ `report.sections` is the channel for captured output. Only the terminal prints it, and only when
1095
+ `--show-capture` allows, so PyCharm's runner, a JUnit body and a run hiding captured output all got
1096
+ the message with nothing under it. A custom item or a plugin may render the failure as a string or
1097
+ as its own representation with no sections, and every reader prints its text, the one xdist sends
1098
+ too, so the section is written into that text. A failure with no representation at all leaves the
1099
+ report as the only channel.
1100
+ """
1101
+ longrepr = report.longrepr
1102
+ add = getattr(longrepr, "addsection", None)
1103
+ if callable(add):
1104
+ add(name, content)
1105
+ elif longrepr is not None:
1106
+ report.longrepr = f"{longrepr}\n{f' {name} '.center(80, '-')}\n{content}"
1107
+ else:
1108
+ report.sections.append((name, content))
1109
+
1110
+
1111
+ def _value_rows(actual: object, expected: object, *, paired: bool, named_actual: bool) -> list[str]:
1112
+ """The rows of a report section for the values a failure named, a side it did not name left unread.
1113
+
1114
+ Cut like a diff row, the untouched values staying on the exception, and whole where `errors._WHOLE_VALUES`
1115
+ says so. A pair is told apart by class where its two sides read alike, cut or whole. For a failed
1116
+ equality the caller hands the pair compared (`_compared_pair`): under ``ignore=`` or ``include=`` alone the
1117
+ two views without the keys left out, in both modes. Beside a compare option no pair is kept, and the rows
1118
+ are the two values.
1119
+ """
1120
+ if not paired:
1121
+ side = _safe_repr if errors._WHOLE_VALUES else _diff_side
1122
+ return [f" actual: {side(actual)}"] if named_actual else [f" expected: {side(expected)}"]
1123
+ if errors._WHOLE_VALUES:
1124
+ left, right = _told_apart(_safe_repr(actual), _safe_repr(expected), actual, expected)
1125
+ else:
1126
+ # windowed as a pair: capping each side alone prints two values that look identical while saying they differ
1127
+ left, right = _diff_sides(actual, expected)
1128
+ return [f" actual: {left}", f" expected: {right}"]
1129
+
1130
+
1131
+ def _attach_report_sections(item, report, exc, *, suffix: str = "") -> None:
1132
+ """Add the readable detail a failure of ours carries: the named values, the diff, the poll trace.
1133
+
1134
+ *suffix* tells one failure of a group from the next on every title.
1135
+ """
1136
+ actual = getattr(exc, "actual", None)
1137
+ expected = getattr(exc, "expected", None)
1138
+ named_actual, named_expected = _named(exc)
1139
+
1140
+ # what decides is `actual` being named, a value other than the subject; no record is the older path
1141
+ named_values = named_actual if getattr(exc, "_outcome", None) is not None else (named_actual or named_expected)
1142
+
1143
+ # windowed over whole values, a row could show a key left out and cut away what the failure is about
1144
+ compared = _compared_pair(exc) if named_values and named_actual and named_expected else None
1145
+ if compared is not None:
1146
+ actual, expected = compared
1147
+ sections = _sections_of(
1148
+ item,
1149
+ actual,
1150
+ expected,
1151
+ getattr(exc, "diff", None),
1152
+ getattr(exc, "trace", None),
1153
+ named_values=named_values,
1154
+ named_actual=named_actual,
1155
+ named_expected=named_expected,
1156
+ suffix=suffix,
1157
+ )
1158
+ _hang(report, sections)
1159
+
1160
+
1161
+ def _sections_of(
1162
+ item, actual, expected, diff, trace, *, named_values: bool, named_actual: bool, named_expected: bool, suffix: str
1163
+ ) -> list[tuple[str, str, str | None]]:
1164
+ """The sections one failure gets, as ``(title, text, colored text or None)``, rendered and not yet attached.
1165
+
1166
+ Each is drawn behind a barrier of its own, so a diff or a trace that cannot be drawn costs the others nothing.
1167
+ """
1168
+ sections: list[tuple[str, str, str | None]] = []
1169
+ if named_values:
1170
+ with contextlib.suppress(Exception):
1171
+ rows = _value_rows(actual, expected, paired=named_actual and named_expected, named_actual=named_actual)
1172
+ sections.append((f"AssertionFailure{suffix}", "\n".join(rows), None))
1173
+
1174
+ if diff is not None and getattr(item.config, "_assertpy_diff_enabled", True):
1175
+ max_entries = getattr(item.config, "_assertpy_diff_max", 50)
1176
+ with contextlib.suppress(Exception):
1177
+ rendered = _format_diff(diff, max_entries=max_entries)
1178
+ # a diff with no entries renders as nothing, and the section around it read as a diff that found nothing
1179
+ if rendered:
1180
+ colored = None
1181
+ # the plain section stands without its coloured copy
1182
+ with contextlib.suppress(Exception):
1183
+ colored = _format_diff(diff, color=True, max_entries=max_entries)
1184
+ sections.append((f"Structured Diff{suffix}", rendered, colored))
1185
+
1186
+ if trace is not None and getattr(item.config, "_assertpy_diff_enabled", True):
1187
+ with contextlib.suppress(Exception):
1188
+ sections.append((f"Polling Trace{suffix}", _format_trace(trace), None))
1189
+ return sections
1190
+
1191
+
1192
+ def _hang(report: pytest.TestReport, sections: list[tuple[str, str, str | None]]) -> None:
1193
+ for title, text, colored in sections:
1194
+ _add_section(report, title, text)
1195
+ _keep_colour(report, title, colored)
1196
+
1197
+
1198
+ def _keep_colour(report: pytest.TestReport, title: str, colored: str | None) -> None:
1199
+ """Store the coloured copy of a section already on, behind a barrier: it is a copy for the terminal."""
1200
+ if colored is not None:
1201
+ with contextlib.suppress(Exception):
1202
+ vars(report).setdefault(_COLORED, {})[title] = colored
1203
+
1204
+
1205
+ def _hang_each(report: pytest.TestReport, sections: list[tuple[str, str, str | None]]) -> int:
1206
+ """Attach a collected failure's *sections* in order, handing back how many characters went on.
1207
+
1208
+ A representation refusing a section costs that section alone: the next one still goes on.
1209
+ """
1210
+ attached = 0
1211
+ for title, text, colored in sections:
1212
+ try:
1213
+ _add_section(report, title, text)
1214
+ except Exception: # the representation is another plugin's or an item's own, and may refuse
1215
+ continue
1216
+ attached += len(text)
1217
+ _keep_colour(report, title, colored)
1218
+ return attached
1219
+
1220
+
1221
+ _Pair = tuple[object, object] | None
1222
+
1223
+
1224
+ _LISTED = "The summary lists each, its diff in compact rows."
1225
+
1226
+
1227
+ def _carries(exc: BaseException) -> bool:
1228
+ """Whether a soft block left what it had collected on *exc*, the exception that escaped it."""
1229
+ try:
1230
+ return "_assertpy_collected" in vars(exc)
1231
+ except Exception: # an exception without a dictionary carries nothing
1232
+ return False
1233
+
1234
+
1235
+ def _attach_collected(item, report, exc: BaseException, room: _Room, attachments: _Room, within: str) -> None:
1236
+ """Hang the sections and the Allure attachments of every collection *exc* holds, behind one barrier each."""
1237
+ try:
1238
+ collections = _collections(exc)
1239
+ except Exception: # a record of anyone's may raise when its class is asked, which must not cost the report
1240
+ return
1241
+ for label, collected in collections:
1242
+ with contextlib.suppress(Exception):
1243
+ _attach_collected_sections(item, report, collected, room, within=within + label)
1244
+ with contextlib.suppress(Exception):
1245
+ _attach_collected_to_allure(item, collected, attachments, within=within + label)
1246
+
1247
+
1248
+ def _collections(exc: BaseException) -> list[tuple[str, tuple[tuple[AssertionOutcome, _Pair], ...]]]:
1249
+ """The failures a soft block collected that *exc* holds, each with the pair its equality compared.
1250
+
1251
+ The failures a soft block raised as one, and those an exception of the caller's that escaped a block carries
1252
+ beside its note, one collection per block it escaped. Carried ones are labelled as a note's, which numbers them
1253
+ on its own, wherever there is more than one collection to tell apart.
1254
+ """
1255
+ own: tuple[tuple[AssertionOutcome, _Pair], ...] = ()
1256
+ if isinstance(exc, errors.AssertionFailure):
1257
+ failures: object = ()
1258
+ pairs: object = ()
1259
+ # a subclass may answer the names with code of its own, which must not cost the report
1260
+ with contextlib.suppress(Exception):
1261
+ failures = exc.failures
1262
+ with contextlib.suppress(Exception):
1263
+ pairs = exc._compared_pairs
1264
+ own = _paired(failures, pairs)
1265
+ carried: list[tuple[tuple[AssertionOutcome, _Pair], ...]] = []
1266
+ if _carries(exc):
1267
+ with contextlib.suppress(Exception):
1268
+ for records, pairs in vars(exc)["_assertpy_collected"]:
1269
+ found = _paired(records, pairs)
1270
+ if found:
1271
+ carried.append(found)
1272
+ collections = [("", own)] if own else []
1273
+ alone = "note, " if own else ""
1274
+ for position, found in enumerate(carried, 1):
1275
+ collections.append((f"note {position} of {len(carried)}, " if len(carried) > 1 else alone, found))
1276
+ return collections
1277
+
1278
+
1279
+ def _paired(failures: object, pairs: object) -> tuple[tuple[AssertionOutcome, _Pair], ...]:
1280
+ """Each record of *failures* with its pair from *pairs*, or ``None`` where the two do not line up."""
1281
+ if type(failures) is not tuple:
1282
+ return ()
1283
+ if type(pairs) is not tuple or len(pairs) != len(failures):
1284
+ pairs = (None,) * len(failures)
1285
+ return tuple(
1286
+ (failure, pair if type(pair) is tuple and len(pair) == 2 else None)
1287
+ for failure, pair in zip(failures, pairs, strict=True)
1288
+ if isinstance(failure, AssertionOutcome)
1289
+ )
1290
+
1291
+
1292
+ def _attach_collected_sections(
1293
+ item, report, collected: tuple[tuple[AssertionOutcome, _Pair], ...], room: _Room, *, within: str = ""
1294
+ ) -> None:
1295
+ """Give each failure a soft block collected the sections a raised one gets, numbered as its summary numbers it.
1296
+
1297
+ Each section opens on the entry's line: its number, its group in brackets, its headline and its location, so a
1298
+ diff is never read against the wrong one.
1299
+ *within* names the leaf of an exception group the block's failure is. *room* is the report's, so two blocks
1300
+ failing in one teardown share it: twenty failures drawn, and 60 000 characters of the sections that went on. A
1301
+ failure with nothing to show takes none of it, and a section the representation refused takes no characters.
1302
+ Whether a failure fits is judged on all its sections before any goes on, since a refusal cannot be told in
1303
+ advance. One that does not fit takes one of the twenty but no characters, so a smaller one after it still goes
1304
+ on. One section at the end names those left out.
1305
+ """
1306
+ total = len(collected)
1307
+ left: list[int] = []
1308
+ for number, (outcome, compared) in enumerate(collected, 1):
1309
+ sections = _admitted(item, number, outcome, compared, room, f" ({within}failure {number} of {total})")
1310
+ if sections is None:
1311
+ left.append(number)
1312
+ continue
1313
+ room.characters -= _hang_each(report, sections)
1314
+ if left:
1315
+ notice = f"No section for lack of room: {_numbered(left)} of {total}. {_LISTED}"
1316
+ with contextlib.suppress(Exception):
1317
+ _add_section(report, f"Left Out ({within}{_numbered(left)})", notice)
1318
+
1319
+
1320
+ def _admitted(
1321
+ item, number: int, outcome: AssertionOutcome, compared: _Pair, room: _Room, suffix: str
1322
+ ) -> list[tuple[str, str, str | None]] | None:
1323
+ """A collected failure's sections where the room takes them, ``[]`` where it has none to give, ``None`` where it
1324
+ is left out. The room is asked before any drawing, and a failure drawn takes one of the twenty whether or not
1325
+ its sections fit."""
1326
+ # a collected failure that raises when read or drawn costs its own sections, not the next one's
1327
+ try:
1328
+ if not _shows_anything(item, outcome):
1329
+ return []
1330
+ if room.failures == 0:
1331
+ return None
1332
+ room.failures -= 1
1333
+ sections = _rendered(item, number, outcome, compared, suffix)
1334
+ except Exception:
1335
+ return []
1336
+ return None if sum(len(text) for _, text, _ in sections) > room.characters else sections
1337
+
1338
+
1339
+ def _shows_anything(item, outcome: AssertionOutcome) -> bool:
1340
+ """Whether a collected failure would get a section at all, told without drawing one: named values, or a diff
1341
+ with entries or a poll's trace where the diff is not turned off."""
1342
+ if outcome.actual_provided:
1343
+ return True
1344
+ shown = getattr(item.config, "_assertpy_diff_enabled", True)
1345
+ return bool(shown and (_has_entries(outcome.diff) or outcome.trace is not None))
1346
+
1347
+
1348
+ def _has_entries(diff: object) -> bool:
1349
+ """Whether *diff* has entries to show, a diff whose entries cannot be read having none."""
1350
+ try:
1351
+ return bool(getattr(diff, "entries", None))
1352
+ except Exception: # a diff of anyone's, read where its failure's values and trace must still go on
1353
+ return False
1354
+
1355
+
1356
+ def _rendered(
1357
+ item, number: int, outcome: AssertionOutcome, compared: _Pair, suffix: str
1358
+ ) -> list[tuple[str, str, str | None]]:
1359
+ """One collected failure's sections, each opening on the failure's own line of the summary.
1360
+
1361
+ Its values are the pair its equality compared where one was kept, as for a raised failure: under ``ignore=`` or
1362
+ ``include=`` the two copies without the keys left out.
1363
+ """
1364
+ named = outcome.actual_provided and outcome.has_expected
1365
+ if named and compared is not None:
1366
+ actual, expected = compared
1367
+ else:
1368
+ actual, expected = outcome.actual, outcome.expected if outcome.has_expected else None
1369
+ sections = _sections_of(
1370
+ item,
1371
+ actual,
1372
+ expected,
1373
+ outcome.diff,
1374
+ outcome.trace,
1375
+ named_values=outcome.actual_provided,
1376
+ named_actual=outcome.actual_provided,
1377
+ named_expected=outcome.has_expected,
1378
+ suffix=suffix,
1379
+ )
1380
+ line = _entry_line(number, outcome)
1381
+ return [
1382
+ (title, f"{line}\n{text}", None if colored is None else f"{line}\n{colored}")
1383
+ for title, text, colored in sections
1384
+ ]
1385
+
1386
+
1387
+ def _entry_line(number: int, outcome: AssertionOutcome) -> str:
1388
+ """The label a section opens on: the entry's number, its group in brackets, its headline and its location."""
1389
+ headline = outcome.message.splitlines()[0] if outcome.message else ""
1390
+ group = f"[{outcome.group}] " if outcome.group is not None else ""
1391
+ return f"{number}. {group}{_located(outcome.location, headline)}"
1392
+
1393
+
1394
+ def _attach_to_allure(item, report, exc) -> None:
1395
+ """Attach the structured failure to the Allure report, when Allure is installed."""
1396
+ if not _HAS_ALLURE:
1397
+ return
1398
+ named_actual, named_expected = _named(exc)
1399
+ _attach_allure(
1400
+ getattr(exc, "actual", None),
1401
+ getattr(exc, "expected", None),
1402
+ getattr(exc, "diff", None),
1403
+ trace=getattr(exc, "trace", None),
1404
+ mode=getattr(item.config, "_assertpy_allure_mode", "diff"),
1405
+ max_entries=getattr(item.config, "_assertpy_diff_max", 50),
1406
+ requirement=getattr(exc, "requirement", None),
1407
+ # the record's flags, not the section's: a value already in the message is still what a dashboard reads
1408
+ named_actual=named_actual,
1409
+ named_expected=named_expected,
1410
+ )
1411
+
1412
+
1413
+ def _attach_collected_to_allure(
1414
+ item, collected: tuple[tuple[AssertionOutcome, _Pair], ...], room: _Room, *, within: str = ""
1415
+ ) -> None:
1416
+ """Attach the failures a soft block collected, each under its number, on the report's room, when Allure is there.
1417
+
1418
+ Twenty failures tried, shared by every block of the report as its sections share theirs: a failure with nothing
1419
+ to attach takes none of them, one whose attachments were refused takes one. One text attachment at the end
1420
+ names the failures left out. Each attachment is bounded on its own by the diff's rows.
1421
+ """
1422
+ if not _HAS_ALLURE:
1423
+ return
1424
+ total = len(collected)
1425
+ left: list[int] = []
1426
+ mode = getattr(item.config, "_assertpy_allure_mode", "diff")
1427
+ max_entries = getattr(item.config, "_assertpy_diff_max", 50)
1428
+ for number, (outcome, _) in enumerate(collected, 1):
1429
+ # a record of anyone's that raises when read costs its own attachments, not the next one's
1430
+ try:
1431
+ parts = _allure_parts(
1432
+ outcome.actual,
1433
+ outcome.expected if outcome.has_expected else None,
1434
+ outcome.diff,
1435
+ trace=outcome.trace,
1436
+ mode=mode,
1437
+ max_entries=max_entries,
1438
+ requirement=outcome.requirement,
1439
+ named_actual=outcome.actual_provided,
1440
+ named_expected=outcome.has_expected,
1441
+ suffix=f" ({within}failure {number} of {total})",
1442
+ )
1443
+ except Exception:
1444
+ continue
1445
+ if not parts:
1446
+ continue
1447
+ if room.failures == 0:
1448
+ left.append(number)
1449
+ continue
1450
+ room.failures -= 1
1451
+ _attach_parts(parts)
1452
+ if left:
1453
+ with contextlib.suppress(Exception):
1454
+ allure.attach(
1455
+ body=f"No attachment for lack of room: {_numbered(left)} of {total}. {_LISTED}",
1456
+ name=f"Left Out ({within}{_numbered(left)})",
1457
+ attachment_type=allure.attachment_type.TEXT,
1458
+ )
1459
+
1460
+
1461
+ def _format_diff(diff, *, color: bool = False, max_entries: int = 50) -> str:
1462
+ return _render_diff(diff, color=color, max_entries=max_entries)
1463
+
1464
+
1465
+ def _format_trace(trace) -> str:
1466
+ """Render a `PollTrace` as a compact per-poll timeline for the terminal report section."""
1467
+ lines = [f"polled {trace.total_polls} times over {trace.elapsed:.1f}s; {trace.summary}"]
1468
+ if trace.dropped:
1469
+ lines.append(f" ... {trace.dropped} middle samples dropped")
1470
+ for sample in trace.samples:
1471
+ repeats = f" x{sample.repeats}" if sample.repeats > 1 else ""
1472
+ lines.append(f" t=+{sample.elapsed:.1f}s {sample.outcome}{repeats}: {sample.detail}")
1473
+ return "\n".join(lines)
1474
+
1475
+
1476
+ def _trace_to_json(trace):
1477
+ """Serialize a `PollTrace` to the typed attachment JSON, with diffs between distinct samples."""
1478
+ samples = []
1479
+ for sample in trace.samples:
1480
+ item = {"t": round(sample.elapsed, 3), "outcome": sample.outcome, "detail": sample.detail}
1481
+ if sample.value is not None:
1482
+ item["value"] = sample.value
1483
+ if sample.repeats > 1:
1484
+ item["repeats"] = sample.repeats
1485
+ samples.append(item)
1486
+ deltas = []
1487
+ fails = [sample for sample in trace.samples if sample.outcome == "fail"]
1488
+ for previous, current in pairwise(fails):
1489
+ if current.value == previous.value:
1490
+ continue
1491
+ entries = _sub_diff_entries(previous.value, current.value, _ROOT)
1492
+ if entries is None:
1493
+ entries_json = [{"path": ".", "actual": previous.value, "expected": current.value}]
1494
+ else:
1495
+ entries_json = [
1496
+ {"path": entry.path, "actual": entry.actual, "expected": entry.expected} for entry in entries
1497
+ ]
1498
+ deltas.append(
1499
+ {"from_t": round(previous.elapsed, 3), "to_t": round(current.elapsed, 3), "entries": entries_json}
1500
+ )
1501
+ payload = {
1502
+ "format": 2,
1503
+ "kind": "polling-trace",
1504
+ "total_polls": trace.total_polls,
1505
+ "elapsed": round(trace.elapsed, 3),
1506
+ "summary": trace.summary,
1507
+ "samples": samples,
1508
+ }
1509
+ if trace.dropped:
1510
+ payload["dropped"] = trace.dropped
1511
+ if deltas:
1512
+ payload["deltas"] = deltas
1513
+ return json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False)
1514
+
1515
+
1516
+ def _entry_to_json(entry):
1517
+ """One diff entry, with the side that had no value at all named rather than left to inference.
1518
+
1519
+ ``format`` 2 wrote ``null`` for both an absent key and a key whose value really is ``None``, so a
1520
+ consumer of the attachment could not tell "this field is missing" from "this field is null" - the
1521
+ same ambiguity `assertpy.errors.DiffEntry.absent` exists to remove inside the library. The key
1522
+ appears only where a side is genuinely absent, so an entry that simply holds ``None`` is
1523
+ unchanged, and the format is bumped because the meaning of a bare ``null`` is what changed.
1524
+ """
1525
+ item = {
1526
+ "path": str(getattr(entry, "path", "")),
1527
+ "actual": _json_safe(getattr(entry, "actual", None)),
1528
+ "expected": _json_safe(getattr(entry, "expected", None)),
1529
+ }
1530
+ absent = getattr(entry, "absent", None)
1531
+ if absent is not None:
1532
+ item["absent"] = absent
1533
+ steps = getattr(entry, "steps", ())
1534
+ if steps:
1535
+ # `path` is lossy, a mapping key goes through `str()`; absent at the root, which has no location
1536
+ item["steps"] = [_step_to_json(step) for step in steps]
1537
+ return item
1538
+
1539
+
1540
+ def _step_to_json(step):
1541
+ """One hop of a diff entry's machine-readable path.
1542
+
1543
+ ``side`` appears only where it means something: a sequence whose two sides have shifted apart, where
1544
+ an index without a side names two different elements.
1545
+ """
1546
+ item = {"kind": step.kind, "value": _json_safe(step.value)}
1547
+ if step.side is not None:
1548
+ item["side"] = step.side
1549
+ return item
1550
+
1551
+
1552
+ def _diff_to_json(diff, max_entries=50):
1553
+ entries = getattr(diff, "entries", None)
1554
+ if not entries:
1555
+ return None
1556
+ kind = getattr(diff, "kind", "unknown")
1557
+ visible = entries[:max_entries] if max_entries > 0 and len(entries) > max_entries else entries
1558
+ truncated = len(entries) - len(visible)
1559
+ items = [_entry_to_json(entry) for entry in visible]
1560
+ payload = {"format": 4, "kind": kind, "entries": items}
1561
+ if truncated:
1562
+ payload["truncated"] = truncated
1563
+ return json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False)
1564
+
1565
+
1566
+ def _attach_allure(
1567
+ actual,
1568
+ expected,
1569
+ diff,
1570
+ *,
1571
+ named_actual,
1572
+ named_expected,
1573
+ trace=None,
1574
+ mode="diff",
1575
+ max_entries=50,
1576
+ requirement=None,
1577
+ suffix="",
1578
+ ) -> None:
1579
+ """Attach what a failure carries to the Allure report."""
1580
+ _attach_parts(
1581
+ _allure_parts(
1582
+ actual,
1583
+ expected,
1584
+ diff,
1585
+ named_actual=named_actual,
1586
+ named_expected=named_expected,
1587
+ trace=trace,
1588
+ mode=mode,
1589
+ max_entries=max_entries,
1590
+ requirement=requirement,
1591
+ suffix=suffix,
1592
+ )
1593
+ )
1594
+
1595
+
1596
+ def _allure_parts(
1597
+ actual,
1598
+ expected,
1599
+ diff,
1600
+ *,
1601
+ named_actual,
1602
+ named_expected,
1603
+ trace=None,
1604
+ mode="diff",
1605
+ max_entries=50,
1606
+ requirement=None,
1607
+ suffix="",
1608
+ ) -> list[tuple[str, Callable[[], str | None], object]]:
1609
+ """The attachments a failure would get, as ``(name, its body when asked, type)``, none of them drawn yet."""
1610
+ # the flags say what the assertion named, not what the terminal shows, and are required rather than defaulted
1611
+ if mode == "off":
1612
+ return []
1613
+ parts: list[tuple[str, Callable[[], str | None], object]] = []
1614
+ if mode == "full" and (named_actual or named_expected or requirement is not None):
1615
+
1616
+ def values() -> str:
1617
+ # format 3 adds `requirement`, so a consumer branching on the number keeps reading 2 as it did
1618
+ data: dict[str, object] = {"format": 3}
1619
+ if named_actual:
1620
+ data["actual"] = _json_safe(actual)
1621
+ if named_expected:
1622
+ data["expected"] = _json_safe(expected)
1623
+ if requirement is not None:
1624
+ data["requirement"] = {
1625
+ "operation": requirement.operation,
1626
+ "parameters": {name: _json_safe(value) for name, value in requirement.parameters.items()},
1627
+ "negated": requirement.negated,
1628
+ }
1629
+ return json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False)
1630
+
1631
+ parts.append((f"AssertionFailure{suffix}", values, allure.attachment_type.JSON))
1632
+ if _has_entries(diff):
1633
+ parts.append(
1634
+ (
1635
+ f"Structured Diff{suffix}",
1636
+ lambda: _diff_to_json(diff, max_entries=max_entries),
1637
+ allure.attachment_type.JSON,
1638
+ )
1639
+ )
1640
+ if trace is not None:
1641
+ parts.append((f"Polling Trace{suffix}", lambda: _trace_to_json(trace), allure.attachment_type.JSON))
1642
+ return parts
1643
+
1644
+
1645
+ def _attach_parts(parts: list[tuple[str, Callable[[], str | None], object]]) -> None:
1646
+ """Draw and attach each part, one refused or broken costing that part alone."""
1647
+ for name, body, kind in parts:
1648
+ # the writer is Allure's, and the values are anyone's
1649
+ with contextlib.suppress(Exception):
1650
+ allure.attach(body=body(), name=name, attachment_type=kind)