assertpy 2.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
|
@@ -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)
|