assertpy 2.35.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/_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 []