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,6 @@
1
+ """Snapshot testing for text and structured data in Robot Framework."""
2
+
3
+ from .library import SnapshotLibrary
4
+ from .version import __version__
5
+
6
+ __all__ = ["SnapshotLibrary", "__version__"]
@@ -0,0 +1,77 @@
1
+ """Command line: ``python -m SnapshotLibrary unused <output dir> [--delete]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import sys
9
+
10
+ from . import unused as unused_module
11
+
12
+
13
+ def _relative(path) -> str:
14
+ try:
15
+ return os.path.relpath(path)
16
+ except ValueError:
17
+ return str(path)
18
+
19
+
20
+ def unused_command(arguments) -> int:
21
+ result = unused_module.report(arguments.output_directory)
22
+ if result is None:
23
+ print(
24
+ f"No snapshot usage records found below '{arguments.output_directory}'. "
25
+ "Pass the output directory of a run that used SnapshotLibrary."
26
+ )
27
+ return 2
28
+ print(f"Checked {len(result.checked)} suite(s).")
29
+ if result.not_checked:
30
+ print(
31
+ f"Not checked, because not all tests ran and passed: {len(result.not_checked)} suite(s)."
32
+ )
33
+ for source in result.not_checked:
34
+ print(f" {_relative(source)}")
35
+ if result.clean:
36
+ print("No unused snapshots.")
37
+ return 0
38
+ if result.unused:
39
+ print(f"Unused snapshots: {len(result.unused)}")
40
+ for path in result.unused:
41
+ print(f" {_relative(path)}")
42
+ if result.orphan_directories:
43
+ print(f"Snapshot directories without a suite file: {len(result.orphan_directories)}")
44
+ for path in result.orphan_directories:
45
+ print(f" {_relative(path)}")
46
+ if arguments.delete:
47
+ for path in result.unused:
48
+ path.unlink()
49
+ for path in result.orphan_directories:
50
+ shutil.rmtree(path)
51
+ print("Deleted.")
52
+ return 0
53
+ print("Delete them, or run again with --delete.")
54
+ return 1
55
+
56
+
57
+ def main(argv=None) -> int:
58
+ parser = argparse.ArgumentParser(prog="python -m SnapshotLibrary")
59
+ commands = parser.add_subparsers(dest="command", required=True)
60
+ unused = commands.add_parser(
61
+ "unused",
62
+ help="report snapshot files that no test used in a finished run",
63
+ description=(
64
+ "Reads the usage records of a finished robot or pabot run and lists snapshot "
65
+ "files no test used. Exit code 0: nothing unused, 1: unused snapshots found, "
66
+ "2: no records found."
67
+ ),
68
+ )
69
+ unused.add_argument("output_directory", help="the --outputdir of the run")
70
+ unused.add_argument("--delete", action="store_true", help="delete what is reported")
71
+ unused.set_defaults(handler=unused_command)
72
+ arguments = parser.parse_args(argv)
73
+ return arguments.handler(arguments)
74
+
75
+
76
+ if __name__ == "__main__":
77
+ sys.exit(main())
@@ -0,0 +1,149 @@
1
+ """The snapshot decision logic, free of any Robot Framework dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import difflib
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Any, Iterable, List, Optional, Tuple
11
+
12
+ from . import jsonpath, serializers, store, xmlpath
13
+ from .normalizers import Normalizer, apply_all
14
+
15
+
16
+ class Outcome(Enum):
17
+ MATCHED = "matched"
18
+ RECORDED = "recorded"
19
+ UPDATED = "updated"
20
+ MISSING = "missing"
21
+ MISMATCH = "mismatch"
22
+
23
+
24
+ @dataclass
25
+ class Result:
26
+ outcome: Outcome
27
+ path: Path
28
+ actual: str
29
+ expected: Optional[str] = None
30
+ diff: Optional[List[str]] = None
31
+
32
+ @property
33
+ def passed(self) -> bool:
34
+ return self.outcome in (Outcome.MATCHED, Outcome.RECORDED, Outcome.UPDATED)
35
+
36
+
37
+ def split_paths(ignore) -> List[str]:
38
+ """``ignore`` is one JSONPath, several separated by ``;``, or a list."""
39
+ if not ignore:
40
+ return []
41
+ if isinstance(ignore, str):
42
+ ignore = ignore.split(";")
43
+ return [str(path).strip() for path in ignore if str(path).strip()]
44
+
45
+
46
+ def prepare(
47
+ value: Any,
48
+ fmt: str = "auto",
49
+ ignore=None,
50
+ normalizers: Iterable[Normalizer] = (),
51
+ ):
52
+ """Serialise, mask and normalise ``value``. Returns ``(text, extension)``."""
53
+ data, extension = serializers.to_data(value, fmt)
54
+ paths = split_paths(ignore)
55
+ if paths:
56
+ if extension == serializers.JSON:
57
+ data = copy.deepcopy(data)
58
+ for path in paths:
59
+ jsonpath.mask(data, path)
60
+ elif extension == serializers.XML:
61
+ data = serializers.canonical_xml(xmlpath.mask(data, paths))
62
+ else:
63
+ raise ValueError(
64
+ "ignore= needs structured data: JSONPath for a dictionary, a list or "
65
+ "a JSON string with format=json, XPath for XML with format=xml. "
66
+ "For plain text, use a normalizer."
67
+ )
68
+ text = serializers.render(data, extension)
69
+ text = apply_all(text, normalizers)
70
+ return serializers.normalize_text(text), extension
71
+
72
+
73
+ UPDATE_HINT = "If the change is intended, update the snapshot with: --variable REFERENCE_RUN:True"
74
+ # Robot Framework counts a message line longer than this as several lines
75
+ # when it checks a failure message against --maxerrorlines.
76
+ _MESSAGE_LINE_WIDTH = 78
77
+
78
+
79
+ def _message_lines(lines: Iterable[str]) -> int:
80
+ return sum(max(1, -(-len(line) // _MESSAGE_LINE_WIDTH)) for line in lines)
81
+
82
+
83
+ def _omitted_note(count: int) -> str:
84
+ return (
85
+ f"... {count} more diff line{'s' if count != 1 else ''}. The full diff is in the log; "
86
+ "run with --maxerrorlines NONE to show it here."
87
+ )
88
+
89
+
90
+ def mismatch_message(label: str, diff: List[str], max_lines: Optional[int] = None) -> Tuple[str, int]:
91
+ """The failure message for a snapshot that does not match, and how many diff lines it leaves out.
92
+
93
+ Robot Framework removes the middle of a failure message that is longer
94
+ than ``max_lines`` (its ``--maxerrorlines``, ``None`` for no limit). To
95
+ keep the start of the diff readable the message is shortened here
96
+ instead: diff lines are shown from the top until the limit is reached,
97
+ followed by a note on how to see the rest.
98
+ """
99
+ header = [f"Snapshot '{label}' does not match."]
100
+ footer = ["", UPDATE_HINT]
101
+ if max_lines is None or _message_lines(header + diff + footer) <= max_lines:
102
+ return "\n".join(header + diff + footer), 0
103
+ budget = max_lines - _message_lines(header + footer + [_omitted_note(len(diff))])
104
+ shown: List[str] = []
105
+ for line in diff:
106
+ if _message_lines(shown + [line]) > budget:
107
+ break
108
+ shown.append(line)
109
+ omitted = len(diff) - len(shown)
110
+ return "\n".join(header + shown + [_omitted_note(omitted)] + footer), omitted
111
+
112
+
113
+ def unified_diff(expected: str, actual: str) -> List[str]:
114
+ # No path in the header: every message that shows a diff already names the snapshot.
115
+ return list(
116
+ difflib.unified_diff(
117
+ expected.splitlines(),
118
+ actual.splitlines(),
119
+ fromfile="snapshot",
120
+ tofile="actual",
121
+ lineterm="",
122
+ )
123
+ )
124
+
125
+
126
+ def check(path: Path, actual: str, update: bool = False, strict: bool = False) -> Result:
127
+ """Compare ``actual`` with the snapshot at ``path`` and record or update as the mode allows.
128
+
129
+ | Mode | Snapshot missing | Snapshot differs |
130
+ | default | record | mismatch |
131
+ | update | record | overwrite |
132
+ | strict | missing | mismatch |
133
+
134
+ ``update`` wins over ``strict`` when both are set.
135
+ """
136
+ path = Path(path)
137
+ if not path.is_file():
138
+ if strict and not update:
139
+ return Result(Outcome.MISSING, path, actual)
140
+ store.write(path, actual)
141
+ return Result(Outcome.RECORDED, path, actual)
142
+ expected = store.read(path)
143
+ if expected == actual:
144
+ return Result(Outcome.MATCHED, path, actual, expected)
145
+ if update:
146
+ store.write(path, actual)
147
+ return Result(Outcome.UPDATED, path, actual, expected)
148
+ diff = unified_diff(expected, actual)
149
+ return Result(Outcome.MISMATCH, path, actual, expected, diff)
@@ -0,0 +1,96 @@
1
+ """A small JSONPath subset used to mask values before a snapshot is taken.
2
+
3
+ Supported: ``$.key``, ``$['key']``, ``$.list[0]``, ``$.list[*]``, ``$.*`` and
4
+ the recursive ``$..key``. That covers "ignore this field wherever it is"
5
+ without adding a dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any, List, Tuple
12
+
13
+ IGNORED = "<IGNORED>"
14
+
15
+ _TOKEN = re.compile(
16
+ r"""
17
+ \.\.(?P<deep>[^.\[\]]+) # ..key
18
+ | \.(?P<key>[^.\[\]]+) # .key or .*
19
+ | \[\s*(?P<index>-?\d+)\s*\] # [0]
20
+ | \[\s*\*\s*\] # [*]
21
+ | \[\s*(?P<quote>['"])(?P<qkey>.*?)(?P=quote)\s*\] # ['key']
22
+ """,
23
+ re.VERBOSE,
24
+ )
25
+
26
+
27
+ def parse(path: str) -> List[Tuple[str, Any]]:
28
+ path = path.strip()
29
+ if not path.startswith("$"):
30
+ raise ValueError(f"JSONPath '{path}' must start with '$'.")
31
+ tokens: List[Tuple[str, Any]] = []
32
+ position = 1
33
+ while position < len(path):
34
+ match = _TOKEN.match(path, position)
35
+ if not match:
36
+ raise ValueError(f"Unsupported JSONPath syntax in '{path}' at position {position}.")
37
+ if match.group("deep") is not None:
38
+ tokens.append(("deep", match.group("deep")))
39
+ elif match.group("key") is not None:
40
+ key = match.group("key")
41
+ tokens.append(("wild", None) if key == "*" else ("key", key))
42
+ elif match.group("index") is not None:
43
+ tokens.append(("index", int(match.group("index"))))
44
+ elif match.group("qkey") is not None:
45
+ tokens.append(("key", match.group("qkey")))
46
+ else:
47
+ tokens.append(("wild", None))
48
+ position = match.end()
49
+ if not tokens:
50
+ raise ValueError(f"JSONPath '{path}' selects the whole value; nothing would be left to compare.")
51
+ return tokens
52
+
53
+
54
+ def _children(node: Any):
55
+ if isinstance(node, dict):
56
+ return list(node.keys())
57
+ if isinstance(node, list):
58
+ return list(range(len(node)))
59
+ return []
60
+
61
+
62
+ def _apply(node: Any, tokens: List[Tuple[str, Any]], placeholder: str) -> int:
63
+ kind, arg = tokens[0]
64
+ last = len(tokens) == 1
65
+ hits = 0
66
+
67
+ def visit(container, key):
68
+ nonlocal hits
69
+ if last:
70
+ container[key] = placeholder
71
+ hits += 1
72
+ else:
73
+ hits += _apply(container[key], tokens[1:], placeholder)
74
+
75
+ if kind == "key":
76
+ if isinstance(node, dict) and arg in node:
77
+ visit(node, arg)
78
+ elif kind == "index":
79
+ if isinstance(node, list) and -len(node) <= arg < len(node):
80
+ visit(node, arg)
81
+ elif kind == "wild":
82
+ for key in _children(node):
83
+ visit(node, key)
84
+ elif kind == "deep":
85
+ if isinstance(node, dict) and arg in node:
86
+ visit(node, arg)
87
+ for key in _children(node):
88
+ child = node[key]
89
+ if isinstance(child, (dict, list)) and not (last and key == arg):
90
+ hits += _apply(child, tokens, placeholder)
91
+ return hits
92
+
93
+
94
+ def mask(data: Any, path: str, placeholder: str = IGNORED) -> int:
95
+ """Replace every value selected by ``path`` in place. Returns the hit count."""
96
+ return _apply(data, parse(path), placeholder)