assertpy 2.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/_inline.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Inline-snapshot recording: locate the ``matches_inline()`` call in the test source and rewrite it
|
|
2
|
+
to carry the captured value as a literal.
|
|
3
|
+
|
|
4
|
+
Only update mode lives here and touches ``executing``/``asttokens``; the comparison path in
|
|
5
|
+
``snapshot.py`` is a plain equality check that needs none of this.
|
|
6
|
+
|
|
7
|
+
Every run of a call under update mode is seen, the ones that matched included, and what to write is
|
|
8
|
+
decided per call once the run is over. Written as each run went, a parametrised call got both its
|
|
9
|
+
values in one place, ``matches_inline(12)`` for the cases ``1`` and ``2``, and a case that matched the
|
|
10
|
+
old literal was broken by the rewrite another case asked for.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import copy
|
|
17
|
+
import hashlib
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import pprint
|
|
21
|
+
import token
|
|
22
|
+
import tokenize
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Final, TypeVar
|
|
25
|
+
|
|
26
|
+
from .errors import _truncated
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
30
|
+
from types import FrameType
|
|
31
|
+
|
|
32
|
+
_LITERAL_TYPES = (str, int, float, bool, type(None))
|
|
33
|
+
_T = TypeVar("_T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Uncaptured:
|
|
37
|
+
"""What a copy that could not be made leaves behind: the run it belongs to takes no literal."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
UNCAPTURED: Final = Uncaptured()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def captured(state: _T) -> _T | Uncaptured:
|
|
44
|
+
"""A copy of *state* that a later change to it cannot reach, or `UNCAPTURED` where it cannot be copied.
|
|
45
|
+
|
|
46
|
+
A run asked at the end of the session about a literal another run asked for is asked with its options
|
|
47
|
+
as they were: the test may have changed the dictionary it passed by then.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
return copy.deepcopy(state)
|
|
51
|
+
except Exception: # an object deepcopy refuses: its run then takes no literal, the conservative answer
|
|
52
|
+
return UNCAPTURED
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class _Run:
|
|
57
|
+
"""One run of a call under update mode."""
|
|
58
|
+
|
|
59
|
+
text: str | None
|
|
60
|
+
"""The value as it would be written there, or ``None`` for a value that is no literal."""
|
|
61
|
+
|
|
62
|
+
stale: bool
|
|
63
|
+
"""Whether the run asked for a rewrite: the call was empty, or its literal no longer matched."""
|
|
64
|
+
|
|
65
|
+
value: object
|
|
66
|
+
"""The value rebuilt from its literal, asked about at the end of the session, so a later change to the object
|
|
67
|
+
the test passed cannot move it, and the object itself for a value that is no literal. When the run ran it was
|
|
68
|
+
asked about the object itself, whose shared references a comparator may read."""
|
|
69
|
+
|
|
70
|
+
accepts: Callable[[object, object], bool]
|
|
71
|
+
"""Whether this run's own comparison, with its own options, passes for a value against a candidate literal."""
|
|
72
|
+
|
|
73
|
+
source: str
|
|
74
|
+
"""A digest of the file's text the call was located in, which every offset here is counted in."""
|
|
75
|
+
|
|
76
|
+
later: bool = True
|
|
77
|
+
"""Whether the run may be asked at the end of the session about a literal asked for after it ran: only a
|
|
78
|
+
literal value compared by plain data, since a comparator function or an object's own `==` can answer
|
|
79
|
+
differently by then, through state the test changed."""
|
|
80
|
+
|
|
81
|
+
judged: dict[str, bool] = field(default_factory=dict)
|
|
82
|
+
"""What the run answered, when it ran, about its own literal and every one asked for at its call so far."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_SEEN: dict[tuple[str, int, int], list[_Run]] = {}
|
|
86
|
+
"""Every run under update mode, by file and the range of it a rewrite replaces (empty for an insertion)."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ensure_inline_tooling():
|
|
90
|
+
try:
|
|
91
|
+
import asttokens # noqa: F401 # optional dependency, imported to fail early with a clear message
|
|
92
|
+
import executing
|
|
93
|
+
except ImportError:
|
|
94
|
+
raise ImportError(
|
|
95
|
+
"recording inline snapshots requires the 'executing' and 'asttokens' packages."
|
|
96
|
+
" Install them with: pip install assertpy[inline]"
|
|
97
|
+
) from None
|
|
98
|
+
return executing
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_literalable(value: object) -> bool:
|
|
102
|
+
"""Whether ``value`` round-trips as a source literal (dict/list/tuple/set of scalars).
|
|
103
|
+
|
|
104
|
+
By exact type, since a subclass renders as its own repr: an `IntEnum` is an `int` and writes
|
|
105
|
+
``<Status.ACTIVE: 1>``, which is not source at all.
|
|
106
|
+
"""
|
|
107
|
+
if isinstance(value, dict):
|
|
108
|
+
return type(value) is dict and all(is_literalable(key) and is_literalable(item) for key, item in value.items())
|
|
109
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
110
|
+
return type(value) in (list, tuple, set, frozenset) and all(is_literalable(item) for item in value)
|
|
111
|
+
if isinstance(value, float):
|
|
112
|
+
return type(value) is float and math.isfinite(value) # nan/inf/-inf render as bare names, not literals
|
|
113
|
+
return type(value) in _LITERAL_TYPES
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _format_literal(value: object, column: int) -> str:
|
|
117
|
+
"""Render ``value`` as a source literal, wrapping wide values and indenting to ``column``."""
|
|
118
|
+
raw = pprint.pformat(value, sort_dicts=False, width=max(20, 116 - column))
|
|
119
|
+
lines = raw.split("\n")
|
|
120
|
+
if len(lines) == 1:
|
|
121
|
+
return raw
|
|
122
|
+
pad = " " * column
|
|
123
|
+
return lines[0] + "".join(f"\n{pad}{line}" for line in lines[1:])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _opening(atok: Any, call: ast.Call) -> Any:
|
|
127
|
+
"""The token that opens *call*'s arguments: the bracket its last token closes.
|
|
128
|
+
|
|
129
|
+
Not the token after the callee, which in ``(assert_that(v).matches_inline)()`` closes the group.
|
|
130
|
+
"""
|
|
131
|
+
depth = 0
|
|
132
|
+
tok = atok.get_token_from_offset(atok.get_text_range(call)[1] - 1)
|
|
133
|
+
while True:
|
|
134
|
+
if tok.type == token.OP and tok.string in ")]}":
|
|
135
|
+
depth += 1
|
|
136
|
+
elif tok.type == token.OP and tok.string in "([{":
|
|
137
|
+
depth -= 1
|
|
138
|
+
if depth == 0:
|
|
139
|
+
return tok
|
|
140
|
+
tok = atok.prev_token(tok)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _expected_of(call: ast.Call) -> ast.expr | None:
|
|
144
|
+
"""The argument the call passes as ``expected``, or ``None`` where unpacking hides it."""
|
|
145
|
+
if call.args:
|
|
146
|
+
first = call.args[0]
|
|
147
|
+
return None if isinstance(first, ast.Starred) else first
|
|
148
|
+
return next((keyword.value for keyword in call.keywords if keyword.arg == "expected"), None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _is_literal(node: ast.expr) -> bool:
|
|
152
|
+
"""Whether *node* is a literal an update may replace: what `ast.literal_eval` reads, and what `_format_literal`
|
|
153
|
+
writes besides, ``set()`` and ``frozenset(...)`` of a literal, anywhere inside one."""
|
|
154
|
+
if isinstance(node, ast.Call):
|
|
155
|
+
return (
|
|
156
|
+
isinstance(node.func, ast.Name)
|
|
157
|
+
and node.func.id in ("set", "frozenset")
|
|
158
|
+
and not node.keywords
|
|
159
|
+
and len(node.args) <= 1
|
|
160
|
+
and all(_is_literal(argument) for argument in node.args)
|
|
161
|
+
)
|
|
162
|
+
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
|
|
163
|
+
return all(_is_literal(item) for item in node.elts)
|
|
164
|
+
if isinstance(node, ast.Dict):
|
|
165
|
+
return all(key is not None and _is_literal(key) for key in node.keys) and all(map(_is_literal, node.values))
|
|
166
|
+
try:
|
|
167
|
+
ast.literal_eval(node)
|
|
168
|
+
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
|
|
169
|
+
return False
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _installed(filename: str) -> bool:
|
|
174
|
+
"""Whether *filename* lies under a `site-packages` or `dist-packages` folder, where installers put packages.
|
|
175
|
+
|
|
176
|
+
A folder name, not ownership: a package installed with ``pip install --target`` elsewhere is not told
|
|
177
|
+
apart, and inside the rootdir it is written as the project's own source.
|
|
178
|
+
"""
|
|
179
|
+
return not {"site-packages", "dist-packages"}.isdisjoint(os.path.normcase(os.path.normpath(filename)).split(os.sep))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _digest(text: str) -> str:
|
|
183
|
+
"""A digest of a file's text as Python read it, less the final newline `linecache` adds to a file without one."""
|
|
184
|
+
return hashlib.sha256(text.removesuffix("\n").encode("utf-8", "surrogatepass")).hexdigest()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def observe(
|
|
188
|
+
frame: FrameType,
|
|
189
|
+
value: object,
|
|
190
|
+
*,
|
|
191
|
+
creating: bool,
|
|
192
|
+
stale: bool,
|
|
193
|
+
accepts: Callable[[object, object], bool],
|
|
194
|
+
pure: bool = True,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Note one run of the ``matches_inline()`` call executing at *frame*, for the decision at session end.
|
|
197
|
+
|
|
198
|
+
*pure* says the run's comparison options hold no function, so it can be asked again later (`_Run.later`).
|
|
199
|
+
|
|
200
|
+
A call in a file under an installer's folder (`_installed`) runs on behalf of a test and is shared by
|
|
201
|
+
every test reaching it, so it is never rewritten: a literal written there would land in a third-party
|
|
202
|
+
file, and in every project the installation serves.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
TypeError: where the run asks for a rewrite of an argument that is no literal, or one unpacking
|
|
206
|
+
hides: rewritten, ``matches_inline(EXPECTED)`` would stop reading the constant it was written with
|
|
207
|
+
RuntimeError: where the run asks for a rewrite of a call in an installed package
|
|
208
|
+
"""
|
|
209
|
+
if _installed(frame.f_code.co_filename):
|
|
210
|
+
if not stale:
|
|
211
|
+
return
|
|
212
|
+
raise RuntimeError(
|
|
213
|
+
f"update mode does not rewrite {frame.f_code.co_filename}, a file of an installed package:"
|
|
214
|
+
" call matches_inline() from the test itself"
|
|
215
|
+
)
|
|
216
|
+
executing = _ensure_inline_tooling()
|
|
217
|
+
source = executing.Source.for_frame(frame)
|
|
218
|
+
call = source.executing(frame).node
|
|
219
|
+
if not isinstance(call, ast.Call): # pragma: no cover - reached below 3.11 alone, which the coverage run is not
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
"could not locate the matches_inline() call to record the inline snapshot; below Python 3.11 a call"
|
|
222
|
+
" inside an assert statement pytest rewrote, or one given its value through **, may not be found,"
|
|
223
|
+
" so write it on a line of its own with its literal"
|
|
224
|
+
)
|
|
225
|
+
atok = source.asttokens()
|
|
226
|
+
place = _place(atok, call, creating=creating, stale=stale)
|
|
227
|
+
if place is None:
|
|
228
|
+
return
|
|
229
|
+
start, end, separator = place
|
|
230
|
+
column = start - source.text.rfind("\n", 0, start) - 1
|
|
231
|
+
if is_literalable(value):
|
|
232
|
+
literal = _format_literal(value, column)
|
|
233
|
+
text, seen = literal + separator, _built(literal)
|
|
234
|
+
else:
|
|
235
|
+
text, seen = None, value
|
|
236
|
+
run = _Run(text, stale, seen, accepts, _digest(source.text), later=pure and text is not None)
|
|
237
|
+
runs = _SEEN.setdefault((frame.f_code.co_filename, start, end), [])
|
|
238
|
+
runs.append(run)
|
|
239
|
+
if not run.later:
|
|
240
|
+
for other in runs:
|
|
241
|
+
if other.text is not None and other.text not in run.judged and (other is run or other.stale):
|
|
242
|
+
run.judged[other.text] = _asks(accepts, value, other.text)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _place(atok: Any, call: ast.Call, *, creating: bool, stale: bool) -> tuple[int, int, str] | None:
|
|
246
|
+
"""The range a literal takes in *call* and what follows it, or ``None`` for a run with nothing to write there.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
TypeError: where the run asks for a rewrite of an argument that is no literal, or one unpacking hides
|
|
250
|
+
"""
|
|
251
|
+
if creating:
|
|
252
|
+
start = _opening(atok, call).endpos
|
|
253
|
+
return start, start, ", " if call.args or call.keywords else ""
|
|
254
|
+
expected = _expected_of(call)
|
|
255
|
+
if expected is not None and _is_literal(expected):
|
|
256
|
+
start, end = atok.get_text_range(expected)
|
|
257
|
+
return start, end, ""
|
|
258
|
+
if not stale:
|
|
259
|
+
return None
|
|
260
|
+
if expected is None:
|
|
261
|
+
raise TypeError(
|
|
262
|
+
"update mode cannot rewrite this matches_inline(): its expected value is passed through"
|
|
263
|
+
" unpacking, so there is no literal to replace - pass it as the first argument or as expected="
|
|
264
|
+
)
|
|
265
|
+
raise TypeError(
|
|
266
|
+
f"update mode cannot rewrite this matches_inline(): its expected value <{atok.get_text(expected)}>"
|
|
267
|
+
" is not a literal, and writing one in its place would stop the test reading it - write the"
|
|
268
|
+
" literal into the call, or update the value by hand"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _built(literal: str) -> object:
|
|
273
|
+
"""The value *literal* builds in the test: compiled, as Python compiles the file it is written into.
|
|
274
|
+
|
|
275
|
+
Compiling merges equal constants, so the inner tuples of ``((1, 2), (1, 2))`` are one object, which
|
|
276
|
+
`ast.literal_eval` builds twice and a comparator reading shared references tells apart. It also reads
|
|
277
|
+
``frozenset({1})``, which `ast.literal_eval` refuses. The file merges them with its other constants too,
|
|
278
|
+
which a literal compiled alone cannot show. The text is this module's rendering of a literal value
|
|
279
|
+
(`_format_literal`), which calls nothing but `set` and `frozenset`.
|
|
280
|
+
"""
|
|
281
|
+
code = compile(literal, "<inline snapshot>", "eval")
|
|
282
|
+
return eval(code, {"__builtins__": {}, "set": set, "frozenset": frozenset})
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _asks(accepts: Callable[[object, object], bool], actual: object, text: str) -> bool:
|
|
286
|
+
"""Whether *accepts* passes *actual* against the literal *text*: a comparison that raises takes nothing.
|
|
287
|
+
|
|
288
|
+
Against the literal built anew, as the next run compares the test's value with the one in the source: a
|
|
289
|
+
candidate that is the object the run holds, or a copy `deepcopy` hands back as itself, a tuple of numbers,
|
|
290
|
+
is one object, which a comparator by identity takes.
|
|
291
|
+
"""
|
|
292
|
+
candidate = _built(text.removesuffix(", ")) # no literal ends in the separator
|
|
293
|
+
try:
|
|
294
|
+
return accepts(actual, candidate)
|
|
295
|
+
except Exception: # a comparator of the caller's raised on the candidate, which is no reason to break the run
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _taken_by(run: _Run, text: str) -> bool:
|
|
300
|
+
"""Whether *run* takes the literal *text*: as it answered when it ran, or, where it may be asked later, now."""
|
|
301
|
+
if text in run.judged:
|
|
302
|
+
return run.judged[text]
|
|
303
|
+
return run.later and _asks(run.accepts, run.value, text)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _settled(runs: list[_Run]) -> tuple[str | None, bool]:
|
|
307
|
+
"""The literal to write for one call, if any, and whether every run of it takes that literal.
|
|
308
|
+
|
|
309
|
+
A run takes a literal its own comparison passes against, so a case differing only in a field it
|
|
310
|
+
ignores takes the literal another case asked for. The run that asked for the literal is asked too:
|
|
311
|
+
a comparator that holds nothing equal would otherwise get a literal written that fails the next run.
|
|
312
|
+
"""
|
|
313
|
+
asking: dict[str, _Run] = {}
|
|
314
|
+
for run in runs:
|
|
315
|
+
if run.stale and run.text is not None:
|
|
316
|
+
asking.setdefault(run.text, run)
|
|
317
|
+
if not asking:
|
|
318
|
+
return None, True
|
|
319
|
+
for text in asking:
|
|
320
|
+
if all(_taken_by(run, text) for run in runs):
|
|
321
|
+
return text, True
|
|
322
|
+
return None, False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def shipped() -> list[list[object]]:
|
|
326
|
+
"""What this process saw, settled per call as far as one process can: the form an xdist worker sends.
|
|
327
|
+
|
|
328
|
+
One row per call: file, start, end, the literal to write or ``None``, whether that settled every run
|
|
329
|
+
seen here, the distinct texts seen, the digests of the file's text the offsets were counted in, and the
|
|
330
|
+
one literal every run here takes (`_held`), which is all another process can be told of their options.
|
|
331
|
+
"""
|
|
332
|
+
rows: list[list[object]] = []
|
|
333
|
+
for (filename, start, end), runs in _SEEN.items():
|
|
334
|
+
written, settled = _settled(runs)
|
|
335
|
+
texts = list(dict.fromkeys(run.text for run in runs))
|
|
336
|
+
held = written if written is not None else _held(runs, texts)
|
|
337
|
+
rows.append([filename, start, end, written, settled, texts, sorted({run.source for run in runs}), held])
|
|
338
|
+
_SEEN.clear()
|
|
339
|
+
return rows
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _held(runs: list[_Run], texts: list[str | None]) -> str | None:
|
|
343
|
+
"""The literal a process that asked for none would take: the one text it saw, where every run takes it.
|
|
344
|
+
|
|
345
|
+
Only that one can be offered to it: a literal another worker asks for is written only where every
|
|
346
|
+
worker takes it, and a worker that saw two texts, or one no run of its own takes, takes none.
|
|
347
|
+
"""
|
|
348
|
+
if len(texts) != 1 or texts[0] is None:
|
|
349
|
+
return None
|
|
350
|
+
return texts[0] if all(_taken_by(run, texts[0]) for run in runs) else None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _listed(texts: Iterable[object]) -> str:
|
|
354
|
+
shown = [f"<{_truncated(str(text), 60)}>" for text in dict.fromkeys(texts) if text is not None]
|
|
355
|
+
more = f" and {len(shown) - 4} more" if len(shown) > 4 else ""
|
|
356
|
+
return ", ".join(shown[:4]) + more
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _decided(rows: Sequence[Sequence[Any]]) -> tuple[str | None, str]:
|
|
360
|
+
"""What one call gets written, from every process that saw it, or why it gets nothing."""
|
|
361
|
+
seen = [text for row in rows for text in row[5]]
|
|
362
|
+
if not all(row[4] for row in rows):
|
|
363
|
+
if len(set(seen)) == 1 and seen[0] is not None:
|
|
364
|
+
return None, (
|
|
365
|
+
f"matches_inline() would not pass against the literal it would write, {_listed(seen)}, under its"
|
|
366
|
+
" own comparison options, so nothing was written: look at the comparators or the tolerance it is given"
|
|
367
|
+
)
|
|
368
|
+
return None, (
|
|
369
|
+
f"matches_inline() saw values no one literal matches, so nothing was written: {_listed(seen)}."
|
|
370
|
+
" One literal stands for every case that reaches the call: give each case a call of its own,"
|
|
371
|
+
" or use snapshot()"
|
|
372
|
+
)
|
|
373
|
+
written = {row[3] for row in rows if row[3] is not None}
|
|
374
|
+
if not written:
|
|
375
|
+
return None, ""
|
|
376
|
+
text = next(iter(written))
|
|
377
|
+
if len(written) == 1 and all(row[7] == text for row in rows):
|
|
378
|
+
return text, ""
|
|
379
|
+
return None, (
|
|
380
|
+
f"matches_inline() was not settled alike in every xdist worker, so nothing was written: {_listed(seen)}."
|
|
381
|
+
" Whether one literal matches every case depends on the call's comparison options, which only the"
|
|
382
|
+
" process that ran a case can ask: record it with -n 0"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _within(filename: str, root: str) -> bool:
|
|
387
|
+
"""Whether *filename* lies under the folder *root*, both read through their links."""
|
|
388
|
+
return os.path.normcase(os.path.realpath(filename)).startswith(
|
|
389
|
+
os.path.join(os.path.normcase(os.path.realpath(root)), "")
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _read(filename: str) -> tuple[str, str, str]:
|
|
394
|
+
"""A source file's text as Python reads it, its encoding, and the line ending to write it back with.
|
|
395
|
+
|
|
396
|
+
Raises:
|
|
397
|
+
OSError, UnicodeError, SyntaxError: where it cannot be read, or its coding line names no codec
|
|
398
|
+
"""
|
|
399
|
+
with open(filename, "rb") as raw:
|
|
400
|
+
encoding, _ = tokenize.detect_encoding(raw.readline)
|
|
401
|
+
with open(filename, encoding=encoding) as handle:
|
|
402
|
+
text = handle.read()
|
|
403
|
+
newline = handle.newlines # the file's own line ending, detected while reading
|
|
404
|
+
return text, encoding, newline if isinstance(newline, str) else "\n"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def apply_inline_records(rows: Iterable[Sequence[Any]], root: str) -> list[str]:
|
|
408
|
+
"""Write what the run settled, one file at a time, and hand back what could not be written, one line each.
|
|
409
|
+
|
|
410
|
+
Only a file under *root*, the rootdir, is written, only if its text is still the one the calls were
|
|
411
|
+
located in, and only if it still parses with every literal in place. A file with nothing to write or
|
|
412
|
+
report is not opened.
|
|
413
|
+
"""
|
|
414
|
+
by_file: dict[str, dict[tuple[int, int], list[Sequence[Any]]]] = {}
|
|
415
|
+
for row in rows:
|
|
416
|
+
by_file.setdefault(row[0], {}).setdefault((row[1], row[2]), []).append(row)
|
|
417
|
+
problems: list[str] = []
|
|
418
|
+
for filename, sites in by_file.items():
|
|
419
|
+
decided = {site: _decided(site_rows) for site, site_rows in sites.items()}
|
|
420
|
+
if any(text is not None or why for text, why in decided.values()):
|
|
421
|
+
sources = {source for site_rows in sites.values() for row in site_rows for source in row[6]}
|
|
422
|
+
problems.extend(_written(filename, decided, sources, root))
|
|
423
|
+
return problems
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _written(
|
|
427
|
+
filename: str, decided: dict[tuple[int, int], tuple[str | None, str]], sources: set[str], root: str
|
|
428
|
+
) -> list[str]:
|
|
429
|
+
"""Write the literals decided for one file, and hand back what could not be written or decided, one line each."""
|
|
430
|
+
if not _within(filename, root):
|
|
431
|
+
return [f"{filename}: outside the rootdir {root}, so nothing was written: update mode writes only there"]
|
|
432
|
+
try:
|
|
433
|
+
content, encoding, newline = _read(filename)
|
|
434
|
+
except (OSError, UnicodeError, SyntaxError) as e:
|
|
435
|
+
return [f"{filename}: could not be read ({e}), so nothing was written"]
|
|
436
|
+
if sources != {_digest(content)}:
|
|
437
|
+
return [
|
|
438
|
+
f"{filename}: changed while the run went on, so the places recorded in it no longer hold the calls"
|
|
439
|
+
" they were taken from, and nothing was written"
|
|
440
|
+
]
|
|
441
|
+
problems = [
|
|
442
|
+
f"{filename}:{content.count(chr(10), 0, start) + 1}: {why}" for (start, _), (_, why) in decided.items() if why
|
|
443
|
+
]
|
|
444
|
+
edits = sorted(
|
|
445
|
+
((start, end, text) for (start, end), (text, why) in decided.items() if text and not why), reverse=True
|
|
446
|
+
)
|
|
447
|
+
if edits:
|
|
448
|
+
problems.extend(_rewritten(filename, content, encoding, newline, edits))
|
|
449
|
+
return problems
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _rewritten(
|
|
453
|
+
filename: str, content: str, encoding: str, newline: str, edits: list[tuple[int, int, str]]
|
|
454
|
+
) -> list[str]:
|
|
455
|
+
"""Put the literals into one file, in its own encoding and line ending, or say why it was left as it was.
|
|
456
|
+
|
|
457
|
+
Its new modification time is a whole second past the old one, since pytest and CPython both key a
|
|
458
|
+
cached `.pyc` on the whole second and the size: a same-size rewrite within the second was read back
|
|
459
|
+
as the old literal by the next run.
|
|
460
|
+
"""
|
|
461
|
+
rewritten = content
|
|
462
|
+
for start, end, text in edits:
|
|
463
|
+
rewritten = rewritten[:start] + text + rewritten[end:]
|
|
464
|
+
try:
|
|
465
|
+
ast.parse(rewritten)
|
|
466
|
+
except SyntaxError as e:
|
|
467
|
+
return [f"{filename}: the recorded literals would not parse ({e.msg}, line {e.lineno}), so nothing was written"]
|
|
468
|
+
try:
|
|
469
|
+
data = rewritten.replace("\n", newline).encode(encoding)
|
|
470
|
+
except UnicodeError as e:
|
|
471
|
+
return [
|
|
472
|
+
f"{filename}: a recorded literal cannot be written in the file's encoding ({e}), so nothing was written"
|
|
473
|
+
]
|
|
474
|
+
try:
|
|
475
|
+
before = os.stat(filename)
|
|
476
|
+
with open(filename, "wb") as handle:
|
|
477
|
+
handle.write(data)
|
|
478
|
+
after = os.stat(filename)
|
|
479
|
+
past = (int(before.st_mtime) + 1) * 1_000_000_000
|
|
480
|
+
if after.st_mtime_ns < past:
|
|
481
|
+
os.utime(filename, ns=(after.st_atime_ns, past))
|
|
482
|
+
except OSError as e:
|
|
483
|
+
return [f"{filename}: could not be written ({e}), so it may hold part of the rewrite: check it"]
|
|
484
|
+
return []
|