robotframework-snapshot 0.1.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.
@@ -0,0 +1,519 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, List, Optional, Union
8
+
9
+ from robot.api import logger
10
+ from robot.api.deco import keyword, library
11
+ from robot.libraries.BuiltIn import BuiltIn
12
+ from robot.utils import is_truthy
13
+
14
+ from . import core, normalizers as normalizing, serializers, store, unused as usage_tracking
15
+ from .core import Outcome
16
+ from .version import __version__
17
+
18
+ ACTUAL_DIR_NAME = "snapshot_actual"
19
+ SCOPES = ("test", "suite", "global")
20
+ # The clickable line above a folded diff, styled as a button that works on the light and the dark log theme.
21
+ SUMMARY_STYLE = (
22
+ "display:list-item; list-style-position:inside; width:fit-content; cursor:pointer; "
23
+ "padding:3px 10px; border:1px solid #8c959f; border-radius:6px; "
24
+ "background:rgba(140,149,159,0.15); font-weight:bold"
25
+ )
26
+
27
+
28
+ @library(scope="GLOBAL", version=__version__, doc_format="ROBOT")
29
+ class SnapshotLibrary:
30
+ """Checks text and data against a stored expected value, without writing that value by hand.
31
+
32
+ The first time a test runs, the value is saved to a file next to the
33
+ suite. Every run after that compares against the file and fails with a
34
+ diff when something changed.
35
+
36
+ | *** Settings ***
37
+ | Library Process
38
+ | Library SnapshotLibrary
39
+ |
40
+ | *** Test Cases ***
41
+ | Help Text Is Stable
42
+ | ${result}= Run Process mytool --help
43
+ | Should Match Snapshot ${result.stdout}
44
+
45
+ The first run writes ``__snapshots__/<suite file name>/Help_Text_Is_Stable.txt``
46
+ and passes with a warning. Check the file and commit it with your tests.
47
+ From then on the test fails when the output changes, and the failure
48
+ message shows a diff. When the change is intended, run once with
49
+ ``--variable REFERENCE_RUN:True`` to overwrite the snapshot.
50
+
51
+ = Modes =
52
+
53
+ | =Mode= | =How to switch it on= | =Snapshot file missing= | =Value differs from the file= |
54
+ | Default | nothing | Recorded, test passes with a warning | Test fails with a diff |
55
+ | Update | ``--variable REFERENCE_RUN:True`` | Recorded | File is overwritten, test passes |
56
+ | Strict | ``--variable SNAPSHOT_STRICT:True`` or ``strict=True`` | Test fails | Test fails with a diff |
57
+
58
+ Use strict mode in CI, so a snapshot that was never committed fails the
59
+ build instead of being recorded on the build machine.
60
+
61
+ The failure message shows as much of the diff as fits within
62
+ ``--maxerrorlines``; the log has the full diff in colour. With
63
+ ``save_actual=True`` or ``--variable SNAPSHOT_SAVE_ACTUAL:True`` the full
64
+ actual value is also written to ``${OUTPUT_DIR}/snapshot_actual/``.
65
+
66
+ = How snapshot files are found =
67
+
68
+ The location follows from where the keyword is called, there is no
69
+ setting per file:
70
+ ``<folder of the suite file>/__snapshots__/<suite file name>/<test name>.<extension>``.
71
+
72
+ - Text is stored as ``.txt``, dictionaries and lists as ``.json`` with
73
+ sorted keys, XML as ``.xml`` with sorted attributes. A list of rows is
74
+ written one row per line.
75
+ - A second snapshot in the same test gets the suffix ``__2``, a third
76
+ ``__3``, or ``__<name>`` when ``name=`` is given.
77
+ - With ``shared=True`` the file is ``<name>.<extension>`` without the test
78
+ name, so several tests compare against one file.
79
+ - A snapshot taken in a suite setup or teardown is stored as ``__suite__``.
80
+ - ``snapshot_directory`` on import, or `Set Snapshot Directory`, puts all
81
+ suite folders in one directory instead.
82
+
83
+ = Values that change every run =
84
+
85
+ Normalizers replace changing text with a placeholder before comparing and
86
+ before recording, for example ``2026-03-14 09:26:53`` with
87
+ ``<TIMESTAMP>``. Built in: ``timestamp``, ``timezone``, ``uuid``,
88
+ ``duration`` and ``path``. Add your own with `Add Snapshot Normalizer`.
89
+
90
+ For JSON, ``ignore=`` masks fields by JSONPath (``$.id``,
91
+ ``$..updated_at``), and for XML by XPath (``.//created``, ``@id``).
92
+
93
+ = Unused snapshots =
94
+
95
+ When a test is renamed or removed, its snapshot file stays behind. At the
96
+ end of each suite the library warns about files in that suite's snapshot
97
+ directory that no test used. It only does so when every test of the suite
98
+ ran and passed, because a filtered, skipped or failed test would make its
99
+ snapshot look unused. Turn the warning off with ``warn_unused=False``.
100
+
101
+ A parallel run with ``pabot --testlevelsplit`` runs each test in its own
102
+ process, so no process sees a whole suite. Check such a run afterwards,
103
+ from the command line:
104
+
105
+ | python -m SnapshotLibrary unused <output directory>
106
+ | python -m SnapshotLibrary unused <output directory> --delete
107
+
108
+ The command exits with 1 when it finds unused snapshots, so it can fail a
109
+ build. It also reports snapshot directories whose suite file is gone.
110
+
111
+ = Images, PDFs and screenshots =
112
+
113
+ This library does not compare visual content. Use
114
+ [https://github.com/manykarim/robotframework-doctestlibrary|DocTestLibrary]
115
+ for that. Both libraries read the same ``REFERENCE_RUN`` variable, so one
116
+ run updates text snapshots and visual baselines together.
117
+ """
118
+
119
+ ROBOT_LISTENER_API_VERSION = 3
120
+
121
+ def __init__(
122
+ self,
123
+ snapshot_directory: Optional[str] = None,
124
+ normalizers: Optional[Union[str, List[str]]] = None,
125
+ strict: bool = False,
126
+ warn_unused: bool = True,
127
+ save_actual: bool = False,
128
+ ):
129
+ """
130
+ | =Argument= | =Description= |
131
+ | ``snapshot_directory`` | Directory for all snapshots. Relative paths resolve against ``${EXECDIR}``. Default: ``__snapshots__`` next to each suite file. |
132
+ | ``normalizers`` | Built-in normalizers applied to every snapshot, comma separated, for example ``timestamp,uuid``. |
133
+ | ``strict`` | Fail when a snapshot is missing instead of recording it. Can also be set per run with ``--variable SNAPSHOT_STRICT:True``. |
134
+ | ``warn_unused`` | Warn at the end of a suite about snapshot files no test used. See `Unused snapshots`. |
135
+ | ``save_actual`` | When a snapshot does not match, also write the actual value to ``${OUTPUT_DIR}/snapshot_actual/``. Can also be set per run with ``--variable SNAPSHOT_SAVE_ACTUAL:True``. |
136
+ """
137
+ # The library is its own listener (API v3), to know when tests and suites start and end.
138
+ self.ROBOT_LIBRARY_LISTENER = self
139
+ self.snapshot_directory = snapshot_directory
140
+ self.strict = is_truthy(strict)
141
+ self.warn_unused = is_truthy(warn_unused)
142
+ self.save_actual = is_truthy(save_actual)
143
+ self._usage: List[usage_tracking.SuiteUsage] = []
144
+ self._global_normalizers = [normalizing.builtin(name) for name in normalizing.split_names(normalizers)]
145
+ self._suite_normalizers: List[List[normalizing.Normalizer]] = []
146
+ self._test_normalizers: List[normalizing.Normalizer] = []
147
+ self._unnamed_count = 0
148
+
149
+ # -- listener: scopes and per-test numbering ---------------------------
150
+
151
+ def _start_suite(self, data, result):
152
+ self._suite_normalizers.append([])
153
+ self._unnamed_count = 0
154
+ if data.parent is None:
155
+ output_dir = self._variable("${OUTPUT DIR}")
156
+ if output_dir:
157
+ usage_tracking.clear_records(output_dir)
158
+ self._usage.append(usage_tracking.SuiteUsage(str(data.source or ""), self._count_tests(data.source)))
159
+
160
+ def _end_suite(self, data, result):
161
+ if self._suite_normalizers:
162
+ self._suite_normalizers.pop()
163
+ if self._usage:
164
+ self._close_usage(self._usage.pop(), data, result)
165
+
166
+ def _start_test(self, data, result):
167
+ self._test_normalizers = []
168
+ self._unnamed_count = 0
169
+
170
+ def _end_test(self, data, result):
171
+ self._test_normalizers = []
172
+ self._unnamed_count = 0
173
+ if self._usage:
174
+ self._usage[-1].ran[result.name] = result.status
175
+
176
+ # -- unused snapshots ----------------------------------------------------
177
+
178
+ @staticmethod
179
+ def _count_tests(source) -> Optional[int]:
180
+ """Number of tests the suite file defines, to notice a filtered run."""
181
+ if not source:
182
+ return None
183
+ source = Path(source)
184
+ if source.is_dir():
185
+ return 0
186
+ try:
187
+ from robot.api import TestSuite
188
+
189
+ return len(TestSuite.from_file_system(str(source)).tests)
190
+ except Exception: # an unreadable suite must never break the run
191
+ return None
192
+
193
+ def _is_dry_run(self) -> bool:
194
+ try:
195
+ from robot.running.context import EXECUTION_CONTEXTS
196
+
197
+ return bool(EXECUTION_CONTEXTS.current.dry_run)
198
+ except Exception:
199
+ return False
200
+
201
+ def _close_usage(self, usage, data, result):
202
+ if not usage.source or self._is_dry_run():
203
+ return
204
+ own_tests_only = Path(usage.source).is_file()
205
+ # A directory suite fails when any test below it fails; that says nothing
206
+ # about its own snapshots, but there is no cheaper safe signal.
207
+ usage.suite_passed = True if own_tests_only else bool(result.passed)
208
+ # has_setup first: a suite without setup still has a placeholder object whose status is FAIL
209
+ if own_tests_only and (
210
+ (result.has_setup and result.setup.failed) or (result.has_teardown and result.teardown.failed)
211
+ ):
212
+ usage.suite_passed = False
213
+ default_directory = self._snapshot_base(Path(usage.source)) / store.sanitize(self._suite_folder(Path(usage.source)))
214
+ usage.directories.add(str(default_directory))
215
+ output_dir = self._variable("${OUTPUT DIR}")
216
+ if output_dir:
217
+ try:
218
+ usage_tracking.write_record(output_dir, usage)
219
+ except OSError as error:
220
+ logger.debug(f"Could not write the snapshot usage record: {error}")
221
+ if self.warn_unused and usage.complete:
222
+ unused = usage.unused()
223
+ if unused:
224
+ listing = ", ".join(self._label(path) for path in unused)
225
+ logger.warn(
226
+ f"Unused snapshot{'s' if len(unused) > 1 else ''}: {listing}. "
227
+ "No test used them in this run. Delete them if they are no longer needed."
228
+ )
229
+
230
+ def _mark_used(self, path: Path):
231
+ if self._usage:
232
+ self._usage[-1].used.add(str(path))
233
+ self._usage[-1].directories.add(str(path.parent))
234
+
235
+ # -- keywords ----------------------------------------------------------
236
+
237
+ @keyword
238
+ def should_match_snapshot(
239
+ self,
240
+ value: Any,
241
+ name: Optional[str] = None,
242
+ ignore: Optional[Union[str, List[str]]] = None,
243
+ normalizers: Optional[Union[str, List[str]]] = None,
244
+ format: str = "auto",
245
+ shared: bool = False,
246
+ ):
247
+ """Compares ``value`` with its stored snapshot.
248
+
249
+ If the snapshot does not exist yet it is recorded and the keyword
250
+ passes with a warning. See `Modes` for update and strict
251
+ mode.
252
+
253
+ | =Argument= | =Description= |
254
+ | ``value`` | A string, or anything that can be written as JSON: dictionary, list, number, boolean. |
255
+ | ``name`` | Name for this snapshot. Needed only to give several snapshots in one test readable file names. |
256
+ | ``ignore`` | Values to mask. JSONPath for structured data, for example ``$.id`` or ``$..updated_at``. XPath for XML, for example ``.//timestamp`` or ``.//order/@id``. Several paths: separate with ``;`` or pass a list. |
257
+ | ``normalizers`` | Extra normalizers for this call only, comma separated. |
258
+ | ``format`` | ``auto`` (default), ``text``, ``json`` or ``xml``. Use ``json`` or ``xml`` to store a JSON or XML string sorted and indented, so key order, attribute order and layout do not matter. XML elements, for example from the XML library, are detected automatically. |
259
+ | ``shared`` | Store the snapshot under ``name`` alone, without the test name, so several tests in the suite compare against the same file. Needs ``name``. |
260
+
261
+ Examples:
262
+ | `Should Match Snapshot` ${output}
263
+ | `Should Match Snapshot` ${rows} name=runs normalizers=timezone
264
+ | `Should Match Snapshot` ${response.json()} ignore=$.id;$..updated_at
265
+ | `Should Match Snapshot` ${response.text} format=json
266
+ | `Should Match Snapshot` ${soap_body} format=xml
267
+ | `Should Match Snapshot` ${help_text} name=help shared=True
268
+ """
269
+ text, extension = core.prepare(value, format, ignore, self._active_normalizers(normalizers))
270
+ self._assert(text, extension, name, shared)
271
+
272
+ @keyword
273
+ def should_match_file_snapshot(
274
+ self,
275
+ path: str,
276
+ name: Optional[str] = None,
277
+ normalizers: Optional[Union[str, List[str]]] = None,
278
+ encoding: str = "UTF-8",
279
+ shared: bool = False,
280
+ format: str = "text",
281
+ ):
282
+ """Compares the text content of the file at ``path`` with its stored snapshot.
283
+
284
+ Use it for files the system under test produced: reports, exports,
285
+ generated configuration. The snapshot keeps the file's extension.
286
+
287
+ By default the content is compared as text. With ``format=json`` or
288
+ ``format=xml`` it is stored sorted and indented, so key order, attribute
289
+ order and indentation do not matter.
290
+
291
+ Examples:
292
+ | `Should Match File Snapshot` ${OUTPUT_DIR}/export.csv
293
+ | `Should Match File Snapshot` ${TEMPDIR}/report.html normalizers=timestamp
294
+ | `Should Match File Snapshot` ${OUTPUT_DIR}/config.xml format=xml
295
+ """
296
+ source = Path(path)
297
+ if not source.is_file():
298
+ raise AssertionError(f"File '{path}' does not exist.")
299
+ with open(source, "r", encoding=encoding, newline="") as file:
300
+ content = file.read()
301
+ if (format or "text").lower() == "auto":
302
+ raise ValueError("format=auto is not supported for files. Use text, json or xml.")
303
+ text, _ = core.prepare(content, format, None, self._active_normalizers(normalizers))
304
+ extension = source.suffix.lstrip(".") or serializers.TEXT
305
+ self._assert(text, extension, name, shared)
306
+
307
+ @keyword
308
+ def add_snapshot_normalizer(
309
+ self,
310
+ name: str,
311
+ pattern: Optional[str] = None,
312
+ replacement: Optional[str] = None,
313
+ scope: str = "suite",
314
+ ):
315
+ """Adds a normalizer that replaces changing text, such as timestamps or IDs, with a placeholder before comparing and recording.
316
+
317
+ With only ``name`` a built-in normalizer is enabled: ``timestamp``,
318
+ ``timezone``, ``uuid``, ``duration`` or ``path``. With ``pattern`` a
319
+ custom one is registered; ``replacement`` defaults to ``<NAME>`` and
320
+ may use regular expression groups such as ``\\\\1``.
321
+
322
+ ``scope`` is ``test``, ``suite`` (default) or ``global``. A test-scoped
323
+ normalizer is dropped when the test ends, a suite-scoped one when the
324
+ suite ends.
325
+
326
+ Examples:
327
+ | `Add Snapshot Normalizer` timestamp
328
+ | `Add Snapshot Normalizer` order_id pattern=ORD-\\\\d+ scope=test
329
+ | `Add Snapshot Normalizer` port pattern=(localhost):\\\\d+ replacement=\\\\1:<PORT>
330
+ """
331
+ scope = scope.strip().lower()
332
+ if scope not in SCOPES:
333
+ raise ValueError(f"Unknown scope '{scope}'. Use one of: {', '.join(SCOPES)}.")
334
+ if pattern is None:
335
+ normalizer = normalizing.builtin(name)
336
+ else:
337
+ if replacement is None:
338
+ replacement = f"<{name.upper()}>"
339
+ normalizer = normalizing.regex_normalizer(name, pattern, replacement)
340
+ if scope == "global":
341
+ self._global_normalizers.append(normalizer)
342
+ elif scope == "test" and self._in_test():
343
+ self._test_normalizers.append(normalizer)
344
+ else:
345
+ if not self._suite_normalizers:
346
+ self._suite_normalizers.append([])
347
+ self._suite_normalizers[-1].append(normalizer)
348
+
349
+ @keyword
350
+ def set_snapshot_directory(self, snapshot_directory: Optional[str] = None) -> Optional[str]:
351
+ """Changes the directory snapshots are stored in and returns the previous setting.
352
+
353
+ Relative paths resolve against ``${EXECDIR}``. Without an argument the
354
+ default is restored: ``__snapshots__`` next to each suite file.
355
+
356
+ Example:
357
+ | ${previous}= `Set Snapshot Directory` ${EXECDIR}/snapshots/staging
358
+ """
359
+ previous = self.snapshot_directory
360
+ self.snapshot_directory = snapshot_directory or None
361
+ return previous
362
+
363
+ @keyword
364
+ def get_snapshot(self, name: Optional[str] = None, shared: bool = False) -> Any:
365
+ """Returns the stored snapshot, for custom assertions.
366
+
367
+ JSON snapshots are returned as dictionaries or lists, everything else
368
+ as a string. Without ``name`` the first unnamed snapshot of the
369
+ current test is returned. Fails if the snapshot does not exist.
370
+
371
+ Example:
372
+ | ${stored}= `Get Snapshot` name=runs
373
+ """
374
+ base = self._path(name, "*", index=1, shared=shared).with_suffix("")
375
+ matches = sorted(base.parent.glob(base.name + ".*")) if base.parent.is_dir() else []
376
+ matches = [match for match in matches if match.stem == base.name]
377
+ if not matches:
378
+ raise AssertionError(f"Snapshot '{self._label(base)}.*' does not exist.")
379
+ self._mark_used(matches[0])
380
+ text = store.read(matches[0])
381
+ if matches[0].suffix == "." + serializers.JSON:
382
+ return json.loads(text)
383
+ return text
384
+
385
+ # -- internals ---------------------------------------------------------
386
+
387
+ def _assert(self, text: str, extension: str, name: Optional[str], shared: bool = False):
388
+ if name:
389
+ index = 1
390
+ else:
391
+ self._unnamed_count += 1
392
+ index = self._unnamed_count
393
+ path = self._path(name, extension, index, shared)
394
+ self._mark_used(path)
395
+ label = self._label(path)
396
+ update = is_truthy(self._variable("${REFERENCE_RUN}", False))
397
+ strict = self.strict or is_truthy(self._variable("${SNAPSHOT_STRICT}", False))
398
+ result = core.check(path, text, update=update, strict=strict)
399
+
400
+ if result.outcome is Outcome.MATCHED:
401
+ logger.info(f"Snapshot '{label}' matches.")
402
+ elif result.outcome is Outcome.RECORDED:
403
+ message = f"Snapshot '{label}' did not exist and was recorded. Review and commit it."
404
+ if update:
405
+ logger.info(message)
406
+ else:
407
+ logger.warn(message)
408
+ elif result.outcome is Outcome.UPDATED:
409
+ self._log_diff(core.unified_diff(result.expected, result.actual), "Changes written to the snapshot", opened=True)
410
+ logger.info(f"Reference run: snapshot '{label}' was updated.")
411
+ elif result.outcome is Outcome.MISSING:
412
+ raise AssertionError(
413
+ f"Snapshot '{label}' does not exist and strict mode is on. "
414
+ "Record it locally (run without strict mode) and commit the file."
415
+ )
416
+ else:
417
+ actual_path = None
418
+ if self.save_actual or is_truthy(self._variable("${SNAPSHOT_SAVE_ACTUAL}", False)):
419
+ actual_path = self._save_actual(path, text)
420
+ if actual_path:
421
+ logger.info(f"Actual value saved to '{actual_path}'.")
422
+ message, omitted = core.mismatch_message(label, result.diff, self._max_message_lines())
423
+ if omitted:
424
+ shown = len(result.diff) - omitted
425
+ title = f"Full diff in colour. The failure message shows the first {shown} of {len(result.diff)} lines"
426
+ self._log_diff(result.diff, title, opened=True)
427
+ else:
428
+ # Robot Framework already shows the whole diff in the message, so keep this one closed.
429
+ self._log_diff(result.diff, f"Show diff in colour ({len(result.diff)} lines)", opened=False)
430
+ raise AssertionError(message)
431
+
432
+ def _active_normalizers(self, extra) -> List[normalizing.Normalizer]:
433
+ active = list(self._global_normalizers)
434
+ for suite_level in self._suite_normalizers:
435
+ active.extend(suite_level)
436
+ active.extend(self._test_normalizers)
437
+ known = {normalizer.name: normalizer for normalizer in active}
438
+ for name in normalizing.split_names(extra):
439
+ active.append(known.get(name) or normalizing.builtin(name))
440
+ return active
441
+
442
+ @staticmethod
443
+ def _max_message_lines() -> Optional[int]:
444
+ """The --maxerrorlines of the current run, None when it is NONE."""
445
+ try:
446
+ from robot.utils import text
447
+
448
+ return text.MAX_ERROR_LINES
449
+ except Exception: # not public API; fall back to Robot Framework's default
450
+ return 40
451
+
452
+ def _variable(self, name: str, default=None):
453
+ return BuiltIn().get_variable_value(name, default)
454
+
455
+ def _in_test(self) -> bool:
456
+ return bool(self._variable("${TEST NAME}"))
457
+
458
+ def _exec_dir(self) -> Path:
459
+ return Path(self._variable("${EXECDIR}", os.getcwd()))
460
+
461
+ @staticmethod
462
+ def _suite_folder(source: Path) -> str:
463
+ return "__init__" if source.is_dir() else source.stem
464
+
465
+ def _snapshot_base(self, source: Optional[Path]) -> Path:
466
+ """The directory that holds the per-suite snapshot folders."""
467
+ if self.snapshot_directory:
468
+ base = Path(self.snapshot_directory)
469
+ return base if base.is_absolute() else self._exec_dir() / base
470
+ if source is None:
471
+ return self._exec_dir() / store.SNAPSHOT_DIR_NAME
472
+ directory = source if source.is_dir() else source.parent
473
+ return directory / store.SNAPSHOT_DIR_NAME
474
+
475
+ def _path(self, name: Optional[str], extension: str, index: int, shared: bool = False) -> Path:
476
+ source = self._variable("${SUITE SOURCE}")
477
+ source = Path(source) if source else None
478
+ suite = self._suite_folder(source) if source else self._variable("${SUITE NAME}", "suite")
479
+ base = self._snapshot_base(source)
480
+ test = self._variable("${TEST NAME}") or "__suite__"
481
+ if extension == "*":
482
+ return store.snapshot_path(base, suite, test, "x", name, index, shared).with_suffix(".*")
483
+ return store.snapshot_path(base, suite, test, extension, name, index, shared)
484
+
485
+ def _label(self, path: Path) -> str:
486
+ try:
487
+ return Path(os.path.relpath(path, self._exec_dir())).as_posix()
488
+ except ValueError: # different drive on Windows
489
+ return path.as_posix()
490
+
491
+ def _save_actual(self, snapshot: Path, text: str) -> Optional[Path]:
492
+ output_dir = self._variable("${OUTPUT DIR}")
493
+ if not output_dir:
494
+ return None
495
+ target = Path(output_dir) / ACTUAL_DIR_NAME / snapshot.parent.name / snapshot.name
496
+ try:
497
+ store.write(target, text)
498
+ except OSError as error:
499
+ logger.debug(f"Could not save the actual value: {error}")
500
+ return None
501
+ return target
502
+
503
+ @staticmethod
504
+ def _log_diff(diff: List[str], title: str, opened: bool):
505
+ """Logs the diff with removed lines red and added lines green, in a block that can be folded."""
506
+ if not diff:
507
+ return
508
+ styles = {"+": "color:#1a7f37", "-": "color:#cf222e", "@": "color:#6e7781"}
509
+ lines = []
510
+ for line in diff:
511
+ style = styles.get(line[:1], "")
512
+ escaped = html.escape(line)
513
+ lines.append(f'<span style="{style}">{escaped}</span>' if style else escaped)
514
+ # A box that scrolls once it is long, so a big diff does not flood the log.
515
+ logger.info(
516
+ f"<details{' open' if opened else ''}><summary style=\"{SUMMARY_STYLE}\">{html.escape(title)}</summary>"
517
+ '<pre style="margin:4px 0 0; max-height:500px; overflow:auto">' + "\n".join(lines) + "</pre></details>",
518
+ html=True,
519
+ )
@@ -0,0 +1,92 @@
1
+ """Normalizers replace changing text, such as timestamps and IDs, with fixed placeholders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Callable, Dict, Iterable, List, Optional, Union
11
+
12
+ _STAMP = r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,9})?"
13
+ _OFFSET = r"(?:Z|[+-]\d{2}:?\d{2})"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Normalizer:
18
+ name: str
19
+ apply: Callable[[str], str]
20
+
21
+
22
+ def regex_normalizer(name: str, pattern: str, replacement: str) -> Normalizer:
23
+ try:
24
+ compiled = re.compile(pattern)
25
+ except re.error as error:
26
+ raise ValueError(f"Normalizer '{name}' has an invalid pattern: {error}") from None
27
+ return Normalizer(name, lambda text: compiled.sub(replacement, text))
28
+
29
+
30
+ def _path_normalizer() -> Normalizer:
31
+ def apply(text: str) -> str:
32
+ roots: Dict[str, str] = {}
33
+ for label, root in (
34
+ ("<CWD>", os.getcwd()),
35
+ ("<TMP>", tempfile.gettempdir()),
36
+ ("<HOME>", str(Path.home())),
37
+ ):
38
+ if root and len(root) > 1:
39
+ roots.setdefault(root, label)
40
+ # Longest root first, so a temp dir inside the home dir wins over <HOME>.
41
+ for root in sorted(roots, key=len, reverse=True):
42
+ for variant in {root, root.replace("\\", "/"), root.replace("\\", "\\\\")}:
43
+ text = text.replace(variant, roots[root])
44
+ return text
45
+
46
+ return Normalizer("path", apply)
47
+
48
+
49
+ def _builtin() -> Dict[str, Normalizer]:
50
+ return {
51
+ "timestamp": regex_normalizer("timestamp", _STAMP + _OFFSET + "?", "<TIMESTAMP>"),
52
+ "timezone": regex_normalizer("timezone", f"({_STAMP}){_OFFSET}", r"\1<TZ>"),
53
+ "uuid": regex_normalizer(
54
+ "uuid",
55
+ r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
56
+ "<UUID>",
57
+ ),
58
+ "duration": regex_normalizer(
59
+ "duration",
60
+ r"\b\d+(?:\.\d+)?\s?(?:milliseconds|seconds|ms|sec|s)\b",
61
+ "<DURATION>",
62
+ ),
63
+ "path": _path_normalizer(),
64
+ }
65
+
66
+
67
+ BUILTIN_NAMES = ("timestamp", "timezone", "uuid", "duration", "path")
68
+
69
+
70
+ def builtin(name: str) -> Normalizer:
71
+ normalizers = _builtin()
72
+ key = name.strip().lower()
73
+ if key not in normalizers:
74
+ raise ValueError(
75
+ f"Unknown normalizer '{name}'. Built-in normalizers: {', '.join(BUILTIN_NAMES)}. "
76
+ "Register your own with `Add Snapshot Normalizer`."
77
+ )
78
+ return normalizers[key]
79
+
80
+
81
+ def split_names(names: Optional[Union[str, Iterable[str]]]) -> List[str]:
82
+ if not names:
83
+ return []
84
+ if isinstance(names, str):
85
+ names = names.split(",")
86
+ return [str(name).strip() for name in names if str(name).strip()]
87
+
88
+
89
+ def apply_all(text: str, normalizers: Iterable[Normalizer]) -> str:
90
+ for normalizer in normalizers:
91
+ text = normalizer.apply(text)
92
+ return text
File without changes