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,148 @@
1
+ """Turn arbitrary values into stable, readable text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Set
7
+ from datetime import date, datetime, time
8
+ from decimal import Decimal
9
+ from pathlib import PurePath
10
+ from typing import Any, Tuple
11
+ from xml.dom import minidom
12
+ from xml.etree import ElementTree
13
+
14
+ TEXT = "txt"
15
+ JSON = "json"
16
+ XML = "xml"
17
+ FORMATS = ("auto", "text", "json", "xml")
18
+
19
+
20
+ def normalize_text(text: str) -> str:
21
+ """Normalise line endings and end the text with exactly one newline."""
22
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
23
+ return text.rstrip("\n") + "\n"
24
+
25
+
26
+ def to_plain(value: Any) -> Any:
27
+ """Reduce ``value`` to JSON-compatible types with a deterministic layout."""
28
+ if value is None or isinstance(value, (bool, int, float, str)):
29
+ return value
30
+ if isinstance(value, Mapping):
31
+ return {str(key): to_plain(item) for key, item in value.items()}
32
+ if isinstance(value, (list, tuple)):
33
+ return [to_plain(item) for item in value]
34
+ if isinstance(value, Set):
35
+ return sorted((to_plain(item) for item in value), key=repr)
36
+ if isinstance(value, (datetime, date, time)):
37
+ return value.isoformat()
38
+ if isinstance(value, bytes):
39
+ return value.decode("utf-8", errors="replace")
40
+ if isinstance(value, (Decimal, PurePath)):
41
+ return str(value)
42
+ return str(value)
43
+
44
+
45
+ def _is_scalar(value: Any) -> bool:
46
+ return value is None or isinstance(value, (bool, int, float, str))
47
+
48
+
49
+ def _scalar(value: Any) -> str:
50
+ return json.dumps(value, ensure_ascii=False)
51
+
52
+
53
+ def _dump(value: Any, indent: int, in_list: bool) -> str:
54
+ pad, inner = " " * indent, " " * (indent + 1)
55
+ if isinstance(value, dict):
56
+ if not value:
57
+ return "{}"
58
+ items = [
59
+ f"{inner}{_scalar(key)}: {_dump(value[key], indent + 1, False)}"
60
+ for key in sorted(value)
61
+ ]
62
+ return "{\n" + ",\n".join(items) + "\n" + pad + "}"
63
+ if isinstance(value, list):
64
+ if not value:
65
+ return "[]"
66
+ # A row of a table: a list of plain values inside another list.
67
+ # Kept on one line, so a changed row is one changed line in the diff.
68
+ if in_list and all(_is_scalar(item) for item in value):
69
+ return "[" + ", ".join(_scalar(item) for item in value) + "]"
70
+ items = [f"{inner}{_dump(item, indent + 1, True)}" for item in value]
71
+ return "[\n" + ",\n".join(items) + "\n" + pad + "]"
72
+ return _scalar(value)
73
+
74
+
75
+ def dump_json(data: Any) -> str:
76
+ """Valid JSON with sorted keys, two-space indent and one table row per line."""
77
+ return _dump(data, 0, False) + "\n"
78
+
79
+
80
+ def _is_xml_element(value: Any) -> bool:
81
+ if isinstance(value, ElementTree.ElementTree):
82
+ return True
83
+ if type(value).__module__.startswith("lxml"):
84
+ return hasattr(value, "tag") or hasattr(value, "getroot")
85
+ return isinstance(value, ElementTree.Element)
86
+
87
+
88
+ def _element_to_string(value: Any) -> str:
89
+ if type(value).__module__.startswith("lxml"):
90
+ from lxml import etree
91
+
92
+ return etree.tostring(value, encoding="unicode")
93
+ if isinstance(value, ElementTree.ElementTree):
94
+ value = value.getroot()
95
+ return ElementTree.tostring(value, encoding="unicode")
96
+
97
+
98
+ def canonical_xml(source: str) -> str:
99
+ """Canonical XML (C14N 2.0), indented two spaces per level.
100
+
101
+ Attributes are sorted, comments, the XML declaration and the doctype are
102
+ dropped, and whitespace around text and between elements is not
103
+ significant.
104
+ """
105
+ try:
106
+ canonical = ElementTree.canonicalize(source, strip_text=True)
107
+ except ElementTree.ParseError as error:
108
+ raise ValueError(f"format=xml was given but the value is not valid XML: {error}") from None
109
+ return minidom.parseString(canonical).documentElement.toprettyxml(indent=" ")
110
+
111
+
112
+ def to_data(value: Any, fmt: str = "auto") -> Tuple[Any, str]:
113
+ """Return ``(data, extension)``.
114
+
115
+ ``data`` is a string for text and XML snapshots and a JSON-compatible
116
+ structure for JSON snapshots. Masking by JSONPath happens on that structure before
117
+ it is written out with `render`.
118
+ """
119
+ fmt = (fmt or "auto").lower()
120
+ if fmt not in FORMATS:
121
+ raise ValueError(f"Unknown snapshot format '{fmt}'. Use one of: {', '.join(FORMATS)}.")
122
+ if fmt in ("auto", "xml") and _is_xml_element(value):
123
+ return canonical_xml(_element_to_string(value)), XML
124
+ if fmt == "xml":
125
+ if not isinstance(value, (str, bytes)):
126
+ raise ValueError(f"format=xml needs an XML string or element, got {type(value).__name__}.")
127
+ # bytes go to the parser as they are, so an encoding declaration is honoured
128
+ return canonical_xml(value), XML
129
+ if isinstance(value, bytes) and fmt != "json":
130
+ value = value.decode("utf-8", errors="replace")
131
+ if fmt == "text":
132
+ return (value if isinstance(value, str) else str(value)), TEXT
133
+ if fmt == "json":
134
+ if isinstance(value, (str, bytes)):
135
+ try:
136
+ value = json.loads(value)
137
+ except ValueError as error:
138
+ raise ValueError(f"format=json was given but the value is not valid JSON: {error}") from None
139
+ return to_plain(value), JSON
140
+ if isinstance(value, str):
141
+ return value, TEXT
142
+ return to_plain(value), JSON
143
+
144
+
145
+ def render(data: Any, extension: str) -> str:
146
+ if extension == JSON:
147
+ return dump_json(data)
148
+ return normalize_text(data)
@@ -0,0 +1,60 @@
1
+ """Where snapshots live on disk and how they are read and written."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ SNAPSHOT_DIR_NAME = "__snapshots__"
11
+ _UNSAFE = re.compile(r"[^\w.-]+", re.UNICODE)
12
+
13
+
14
+ def sanitize(name: str) -> str:
15
+ """Reduce a suite, test or snapshot name to a safe file name part."""
16
+ cleaned = _UNSAFE.sub("_", str(name)).strip(".")
17
+ if not cleaned.strip("_"):
18
+ raise ValueError(f"Name '{name}' contains no characters usable in a file name.")
19
+ return cleaned
20
+
21
+
22
+ def snapshot_path(
23
+ base_directory: Path,
24
+ suite: str,
25
+ test: str,
26
+ extension: str,
27
+ name: Optional[str] = None,
28
+ index: int = 1,
29
+ shared: bool = False,
30
+ ) -> Path:
31
+ """``<base>/<suite>/<test>[__<name>|__<n>].<ext>``
32
+
33
+ The first unnamed snapshot of a test has no suffix, later unnamed ones are
34
+ numbered from 2 in call order. A shared snapshot is stored under its name
35
+ alone, ``<base>/<suite>/<name>.<ext>``, so several tests can use it.
36
+ """
37
+ if shared:
38
+ if not name:
39
+ raise ValueError("A shared snapshot needs a name: use name=... together with shared=True.")
40
+ return Path(base_directory) / sanitize(suite) / f"{sanitize(name)}.{extension}"
41
+ stem = sanitize(test)
42
+ if name:
43
+ stem += "__" + sanitize(name)
44
+ elif index > 1:
45
+ stem += f"__{index}"
46
+ return Path(base_directory) / sanitize(suite) / f"{stem}.{extension}"
47
+
48
+
49
+ def read(path: Path) -> str:
50
+ with open(path, "r", encoding="utf-8", newline="") as file:
51
+ return file.read().replace("\r\n", "\n").replace("\r", "\n")
52
+
53
+
54
+ def write(path: Path, text: str) -> None:
55
+ """Write atomically, so a parallel process never reads a half-written snapshot."""
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
58
+ with open(temporary, "w", encoding="utf-8", newline="\n") as file:
59
+ file.write(text)
60
+ os.replace(temporary, path)
@@ -0,0 +1,191 @@
1
+ """Find snapshot files that no test used.
2
+
3
+ Every suite writes a small usage record to ``${OUTPUT_DIR}/snapshot_usage/``.
4
+ The library warns at the end of a suite when it can judge that suite by
5
+ itself, and ``python -m SnapshotLibrary unused <output dir>`` merges the
6
+ records of a whole run, which is what a parallel run with pabot needs.
7
+
8
+ A suite is only judged when every one of its tests ran and passed. A test
9
+ that was filtered out, skipped or failed before reaching its snapshot would
10
+ otherwise make that snapshot look unused.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Dict, Iterable, List, Optional, Set
20
+
21
+ from . import store
22
+
23
+ USAGE_DIR_NAME = "snapshot_usage"
24
+ PASS = "PASS"
25
+
26
+
27
+ @dataclass
28
+ class SuiteUsage:
29
+ """What one suite did with snapshots, in one process."""
30
+
31
+ source: str
32
+ total_tests: Optional[int]
33
+ ran: Dict[str, str] = field(default_factory=dict)
34
+ used: Set[str] = field(default_factory=set)
35
+ directories: Set[str] = field(default_factory=set)
36
+ suite_passed: bool = True
37
+
38
+ @property
39
+ def complete(self) -> bool:
40
+ if self.total_tests is None or not self.suite_passed:
41
+ return False
42
+ if len(self.ran) < self.total_tests:
43
+ return False
44
+ return all(status == PASS for status in self.ran.values())
45
+
46
+ def unused(self) -> List[Path]:
47
+ return find_unused(self.directories, self.used)
48
+
49
+ def merge(self, other: "SuiteUsage") -> None:
50
+ if self.total_tests is None:
51
+ self.total_tests = other.total_tests
52
+ for name, status in other.ran.items():
53
+ # a rerun that passes wins over an earlier failure
54
+ if self.ran.get(name) != PASS:
55
+ self.ran[name] = status
56
+ self.used |= other.used
57
+ self.directories |= other.directories
58
+ self.suite_passed = self.suite_passed and other.suite_passed
59
+
60
+ def to_json(self) -> dict:
61
+ return {
62
+ "source": self.source,
63
+ "total_tests": self.total_tests,
64
+ "ran": self.ran,
65
+ "used": sorted(self.used),
66
+ "directories": sorted(self.directories),
67
+ "suite_passed": self.suite_passed,
68
+ }
69
+
70
+ @classmethod
71
+ def from_json(cls, data: dict) -> "SuiteUsage":
72
+ return cls(
73
+ source=data["source"],
74
+ total_tests=data.get("total_tests"),
75
+ ran=dict(data.get("ran", {})),
76
+ used=set(data.get("used", [])),
77
+ directories=set(data.get("directories", [])),
78
+ suite_passed=bool(data.get("suite_passed", True)),
79
+ )
80
+
81
+
82
+ def _key(path) -> str:
83
+ return os.path.normcase(os.path.abspath(str(path)))
84
+
85
+
86
+ def find_unused(directories: Iterable, used: Iterable) -> List[Path]:
87
+ """Files in ``directories`` that are not in ``used``. Hidden files are ignored."""
88
+ used_keys = {_key(path) for path in used}
89
+ unused: List[Path] = []
90
+ for directory in sorted({_key(d): Path(d) for d in directories}.values()):
91
+ if not directory.is_dir():
92
+ continue
93
+ for entry in sorted(directory.iterdir()):
94
+ if entry.is_file() and not entry.name.startswith(".") and _key(entry) not in used_keys:
95
+ unused.append(entry)
96
+ return unused
97
+
98
+
99
+ def write_record(output_directory, usage: SuiteUsage) -> Path:
100
+ directory = Path(output_directory) / USAGE_DIR_NAME
101
+ name = store.sanitize(Path(usage.source).stem or "suite")
102
+ target = directory / f"{name}-{os.getpid()}-{abs(hash(usage.source)) % 10**8}.json"
103
+ store.write(target, json.dumps(usage.to_json(), indent=2) + "\n")
104
+ return target
105
+
106
+
107
+ def clear_records(output_directory) -> None:
108
+ directory = Path(output_directory) / USAGE_DIR_NAME
109
+ if directory.is_dir():
110
+ for entry in directory.glob("*.json"):
111
+ try:
112
+ entry.unlink()
113
+ except OSError:
114
+ pass
115
+
116
+
117
+ def read_records(output_directory) -> List[SuiteUsage]:
118
+ """All usage records below ``output_directory``, merged per suite file.
119
+
120
+ The search is recursive, so the records pabot leaves in
121
+ ``pabot_results/<n>/snapshot_usage/`` are found from the main output dir.
122
+ """
123
+ merged: Dict[str, SuiteUsage] = {}
124
+ for record in sorted(Path(output_directory).rglob(f"{USAGE_DIR_NAME}/*.json")):
125
+ try:
126
+ usage = SuiteUsage.from_json(json.loads(record.read_text(encoding="utf-8")))
127
+ except (OSError, ValueError, KeyError):
128
+ continue
129
+ key = _key(usage.source)
130
+ if key in merged:
131
+ merged[key].merge(usage)
132
+ else:
133
+ merged[key] = usage
134
+ return list(merged.values())
135
+
136
+
137
+ def orphan_directories(usages: Iterable[SuiteUsage]) -> List[Path]:
138
+ """Snapshot directories in the default location whose suite file is gone.
139
+
140
+ Looks at every ``__snapshots__`` directory the run touched and reports
141
+ sub-directories that match no suite file next to it. This catches a
142
+ renamed or deleted suite, which leaves no usage record at all.
143
+ """
144
+ bases = set()
145
+ for usage in usages:
146
+ for directory in usage.directories:
147
+ base = Path(directory).parent
148
+ if base.name == store.SNAPSHOT_DIR_NAME:
149
+ bases.add(base)
150
+ orphans: List[Path] = []
151
+ for base in sorted(bases):
152
+ suite_directory = base.parent
153
+ expected = {"__init__"}
154
+ for entry in suite_directory.iterdir():
155
+ if entry.is_file():
156
+ try:
157
+ expected.add(store.sanitize(entry.stem))
158
+ except ValueError:
159
+ pass
160
+ for entry in sorted(base.iterdir()):
161
+ if entry.is_dir() and entry.name not in expected:
162
+ orphans.append(entry)
163
+ return orphans
164
+
165
+
166
+ @dataclass
167
+ class Report:
168
+ unused: List[Path]
169
+ orphan_directories: List[Path]
170
+ checked: List[str]
171
+ not_checked: List[str]
172
+
173
+ @property
174
+ def clean(self) -> bool:
175
+ return not self.unused and not self.orphan_directories
176
+
177
+
178
+ def report(output_directory) -> Optional[Report]:
179
+ usages = read_records(output_directory)
180
+ if not usages:
181
+ return None
182
+ unused: List[Path] = []
183
+ checked: List[str] = []
184
+ not_checked: List[str] = []
185
+ for usage in sorted(usages, key=lambda u: u.source):
186
+ if usage.complete:
187
+ checked.append(usage.source)
188
+ unused.extend(usage.unused())
189
+ else:
190
+ not_checked.append(usage.source)
191
+ return Report(unused, orphan_directories(usages), checked, not_checked)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,74 @@
1
+ """Mask parts of an XML document by XPath before a snapshot is taken.
2
+
3
+ Uses the XPath subset of ``xml.etree.ElementTree``: ``.//tag``, ``a/b``,
4
+ ``*``, ``[@attr]``, ``[@attr='value']``, ``[tag]`` and ``[1]``. A path that
5
+ ends in ``/@name`` masks that attribute instead of the element. Namespace
6
+ prefixes declared in the document can be used in the path, for example
7
+ ``.//soap:Body``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import io
13
+ from typing import Dict, Iterable
14
+ from xml.etree import ElementTree
15
+
16
+ IGNORED = "<IGNORED>"
17
+
18
+
19
+ def _namespaces(source: str) -> Dict[str, str]:
20
+ found: Dict[str, str] = {}
21
+ for _, (prefix, uri) in ElementTree.iterparse(io.StringIO(source), events=("start-ns",)):
22
+ found.setdefault(prefix, uri)
23
+ return found
24
+
25
+
26
+ def _split(path: str):
27
+ path = path.strip()
28
+ if not path:
29
+ raise ValueError("An empty XPath selects nothing.")
30
+ if path.startswith("/"):
31
+ path = "." + path
32
+ head, _, last = path.rpartition("/")
33
+ if last.startswith("@"):
34
+ return (head or "."), last[1:]
35
+ return path, None
36
+
37
+
38
+ def _expand(name: str, namespaces: Dict[str, str]) -> str:
39
+ prefix, colon, local = name.partition(":")
40
+ if colon and prefix in namespaces:
41
+ return f"{{{namespaces[prefix]}}}{local}"
42
+ return name
43
+
44
+
45
+ def mask(source: str, paths: Iterable[str], placeholder: str = IGNORED) -> str:
46
+ """Return ``source`` with every element or attribute selected by ``paths`` masked.
47
+
48
+ A masked element keeps its tag and attributes, but its content is
49
+ replaced by ``placeholder``.
50
+ """
51
+ namespaces = _namespaces(source)
52
+ root = ElementTree.fromstring(source)
53
+ for path in paths:
54
+ element_path, attribute = _split(path)
55
+ try:
56
+ matches = ElementTree.ElementTree(root).findall(element_path, namespaces)
57
+ except (SyntaxError, KeyError, TypeError) as error:
58
+ raise ValueError(f"Unsupported XPath '{path}': {error}") from None
59
+ for element in matches:
60
+ if attribute is None:
61
+ for child in list(element):
62
+ element.remove(child)
63
+ element.text = placeholder
64
+ else:
65
+ key = _expand(attribute, namespaces)
66
+ if key in element.attrib:
67
+ element.set(key, placeholder)
68
+ # Keep the document's own prefixes instead of ns0, ns1, ...
69
+ for prefix, uri in namespaces.items():
70
+ try:
71
+ ElementTree.register_namespace(prefix, uri)
72
+ except ValueError: # a prefix such as ns0 is reserved; ElementTree picks one itself
73
+ pass
74
+ return ElementTree.tostring(root, encoding="unicode")