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.
- SnapshotLibrary/__init__.py +6 -0
- SnapshotLibrary/__main__.py +77 -0
- SnapshotLibrary/core.py +149 -0
- SnapshotLibrary/jsonpath.py +96 -0
- SnapshotLibrary/library.py +519 -0
- SnapshotLibrary/normalizers.py +92 -0
- SnapshotLibrary/py.typed +0 -0
- SnapshotLibrary/serializers.py +148 -0
- SnapshotLibrary/store.py +60 -0
- SnapshotLibrary/unused.py +191 -0
- SnapshotLibrary/version.py +1 -0
- SnapshotLibrary/xmlpath.py +74 -0
- robotframework_snapshot-0.1.0.dist-info/METADATA +432 -0
- robotframework_snapshot-0.1.0.dist-info/RECORD +16 -0
- robotframework_snapshot-0.1.0.dist-info/WHEEL +4 -0
- robotframework_snapshot-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
SnapshotLibrary/store.py
ADDED
|
@@ -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")
|