flakebox 0.4.4__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.
flakebox/junit.py ADDED
@@ -0,0 +1,139 @@
1
+ """JUnit XML ingestion: pytest, jest, surefire all speak JUnit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sys
7
+ import xml.etree.ElementTree as ET
8
+ from xml.parsers import expat
9
+
10
+ from .quarantine import REASON as QUARANTINE_REASON
11
+
12
+ # outcome per testcase
13
+ PASSED, FAILED, ERROR, SKIPPED = "passed", "failed", "error", "skipped"
14
+
15
+ MAX_MS = 2 ** 63 - 1 # the largest duration an SQLite INTEGER can hold
16
+
17
+
18
+ def test_id(classname: str, name: str) -> str:
19
+ classname = (classname or "").strip()
20
+ name = (name or "").strip()
21
+ return f"{classname}::{name}" if classname else name
22
+
23
+
24
+ def report_sha256(raw: bytes) -> str:
25
+ return hashlib.sha256(raw).hexdigest()
26
+
27
+
28
+ def _is_quarantine_xfail(skipped_el) -> bool:
29
+ """pytest writes an xfail as <skipped type="pytest.xfail"
30
+ message="expected test failure">reason</skipped>. Only the reason our
31
+ own quarantine plugin attaches counts - a hand-written xfail stays a
32
+ skip."""
33
+ if (skipped_el.get("type") or "") != "pytest.xfail":
34
+ return False
35
+ text = (skipped_el.text or "") + " " + (skipped_el.get("message") or "")
36
+ return QUARANTINE_REASON in text
37
+
38
+
39
+ def _warn(msg: str) -> None:
40
+ try:
41
+ print(f"flakebox: warning: {msg}", file=sys.stderr)
42
+ except (OSError, ValueError):
43
+ pass
44
+
45
+
46
+ def duration_ms(text, where: str = "", warn=_warn) -> int:
47
+ """`time` attribute -> milliseconds. Accepts a decimal comma
48
+ (`0,5`, written by some locales' runners); anything else that is not
49
+ a finite, non-negative number of seconds the store can hold (`fast`,
50
+ `inf`, `nan`, `-5`, `1e300`) counts as 0 with a warning rather than
51
+ rejecting the whole report."""
52
+ text = (text or "").strip()
53
+ if not text:
54
+ return 0
55
+ try:
56
+ ms = int(float(text.replace(",", ".")) * 1000)
57
+ except (ValueError, OverflowError): # not a number; or inf / nan
58
+ ms = -1
59
+ if not 0 <= ms <= MAX_MS: # also: negative, or too big for the store
60
+ warn(f"{where}: unreadable time={text!r}, recorded as 0")
61
+ return 0
62
+ return ms
63
+
64
+
65
+ class _Declaration(Exception):
66
+ """expat reported a DOCTYPE or ENTITY declaration."""
67
+
68
+
69
+ def check_no_dtd(raw: bytes, path: str = "") -> None:
70
+ """Reject a report that declares a DOCTYPE or an ENTITY. ElementTree
71
+ performs no entity-expansion protection and JUnit reports never
72
+ legitimately need a DTD, so the document is run through expat first and
73
+ stopped at the first declaration, before anything is expanded.
74
+
75
+ expat does the tokenising - the encoding (UTF-16 included), comments,
76
+ processing instructions and CDATA - so text that merely CONTAINS
77
+ "<!DOCTYPE html>" (captured output in a CDATA section, say) is not a
78
+ declaration and passes. A document expat cannot tokenise is left for
79
+ the real parse to report as not well-formed; no entity can have been
80
+ declared in what it did read, because a declaration needs a DOCTYPE and
81
+ the DOCTYPE would have stopped it first."""
82
+ def refuse(*_args):
83
+ raise _Declaration
84
+
85
+ parser = expat.ParserCreate()
86
+ parser.StartDoctypeDeclHandler = refuse
87
+ parser.EntityDeclHandler = refuse
88
+ try:
89
+ parser.Parse(raw, True)
90
+ except _Declaration:
91
+ raise ValueError(
92
+ f"refusing to parse {path}: XML contains a DOCTYPE or ENTITY "
93
+ "declaration (rejected as a possible XML bomb)") from None
94
+ except expat.ExpatError:
95
+ pass
96
+
97
+
98
+ def parse_bytes(raw: bytes, path: str = "", warn=_warn) -> list[dict]:
99
+ """Return [{test_id, outcome, duration_ms, message}] for every
100
+ <testcase> anywhere in the document - <testsuites>, a bare
101
+ <testsuite>, or suites nested in suites (surefire aggregates)."""
102
+ check_no_dtd(raw, path)
103
+ root = ET.fromstring(raw)
104
+ results = []
105
+ cases = [root] if root.tag == "testcase" else root.iter("testcase")
106
+ for tc in cases:
107
+ tid = test_id(tc.get("classname", ""), tc.get("name", ""))
108
+ ms = duration_ms(tc.get("time"), f"{path} testcase {tid}", warn)
109
+ sk = tc.find("skipped")
110
+ if sk is not None:
111
+ if _is_quarantine_xfail(sk):
112
+ # the generated plugin in xfail mode: the test really
113
+ # ran and really failed, it just couldn't break the
114
+ # build. Record the truth so stats stay honest.
115
+ outcome, message = FAILED, (sk.text or "").strip()[:500]
116
+ else:
117
+ outcome, message = SKIPPED, ""
118
+ elif tc.find("failure") is not None:
119
+ el = tc.find("failure")
120
+ outcome, message = FAILED, (el.get("message", "") or "")[:500]
121
+ elif tc.find("error") is not None:
122
+ el = tc.find("error")
123
+ outcome, message = ERROR, (el.get("message", "") or "")[:500]
124
+ else:
125
+ outcome, message = PASSED, ""
126
+ results.append({"test_id": tid, "outcome": outcome,
127
+ "duration_ms": ms, "message": message})
128
+ return results
129
+
130
+
131
+ def read(path: str) -> bytes:
132
+ with open(path, "rb") as f:
133
+ return f.read()
134
+
135
+
136
+ def parse(path: str, warn=_warn) -> list[dict]:
137
+ """Parse the report at `path` (whole file read first, DTD guard,
138
+ then ElementTree)."""
139
+ return parse_bytes(read(path), path, warn)
flakebox/quarantine.py ADDED
@@ -0,0 +1,194 @@
1
+ """Quarantine appliers: turn the flaky set into something the runner obeys."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shlex
8
+ import subprocess
9
+
10
+ # The reason string the generated plugin attaches. junit.py looks for it
11
+ # to tell a quarantined-and-failed xfail apart from a hand-written xfail.
12
+ REASON = "quarantined: flaky per flakebox"
13
+ MODES = ("skip", "xfail")
14
+
15
+ PYTEST_PLUGIN = '''"""Generated by `flakebox quarantine --format pytest`.
16
+
17
+ Marks the quarantined (flaky) tests. A test is quarantined when it has the
18
+ same name (with any [parameters]) as a quarantined id and the same module
19
+ path and class names, compared as whole components in either spelling: the
20
+ pytest node id `tests/test_mod.py::TestA::test_x` and the JUnit id
21
+ `tests.test_mod.TestA::test_x` are the same test. Only leading directories
22
+ may be missing from one side (CI and your machine can have different
23
+ rootdirs): `test_mod.py::test_x` matches `tests.test_mod::test_x`, but
24
+ `pkg_a/test_mod.py::test_x` never matches `pkg_b/test_mod.py::test_x`.
25
+
26
+ Mode comes from flakebox_quarantine.json ("mode"):
27
+ skip - quarantined tests are skipped (default; they record no result)
28
+ xfail - quarantined tests still run but cannot fail the build; their
29
+ real pass/fail still lands in the JUnit report, so flakebox can
30
+ see them stabilise and tell you to un-quarantine them
31
+
32
+ Usage: drop this file + flakebox_quarantine.json in your repo root and run
33
+ python -m pytest -p flakebox_quarantine
34
+ `python -m pytest` puts the current directory on sys.path; the bare
35
+ `pytest` command does not, so it cannot import this file (if the plugin
36
+ lives in another directory, put that directory on PYTHONPATH).
37
+ Regenerate after `flakebox unquarantine` changes the set.
38
+ """
39
+ import json
40
+ import os
41
+
42
+ import pytest
43
+
44
+ _HERE = os.path.dirname(os.path.abspath(__file__))
45
+ with open(os.path.join(_HERE, "flakebox_quarantine.json"),
46
+ encoding="utf-8") as _f:
47
+ _DATA = json.load(_f)
48
+ QUARANTINED = _DATA["tests"]
49
+ MODE = _DATA.get("mode", "skip")
50
+ REASON = "quarantined: flaky per flakebox"
51
+
52
+
53
+ def _key(test_id):
54
+ """(name, chain) of a JUnit id or a pytest node id, or None.
55
+
56
+ name is the test function plus its [parameters]; chain is the module
57
+ path and the class names as whole components, so `pkg/mod.py::A::t`
58
+ and `pkg.mod.A::t` both give ("t", ("pkg", "mod", "A")). An id with no
59
+ module part gives None: it would match every test of that name.
60
+ """
61
+ # split off the parameters first: they may contain `::`, `/` and `.`
62
+ base, bracket, params = test_id.partition("[")
63
+ parts = base.replace(chr(92), "/").split("::")
64
+ if len(parts) < 2:
65
+ return None
66
+ head = parts[0]
67
+ if head.endswith(".py"):
68
+ head = head[:-3]
69
+ chain = []
70
+ for part in [head.replace("/", ".")] + parts[1:-1]:
71
+ chain.extend(piece for piece in part.split(".") if piece)
72
+ if not chain:
73
+ return None
74
+ return parts[-1] + bracket + params, tuple(chain)
75
+
76
+
77
+ def _index(ids):
78
+ exact, tails = set(), set()
79
+ for test_id in ids:
80
+ key = _key(test_id)
81
+ if key is not None:
82
+ name, chain = key
83
+ exact.add(key)
84
+ tails.update((name, chain[i:]) for i in range(len(chain)))
85
+ return exact, tails
86
+
87
+
88
+ # every quarantined (name, chain), and every tail of a quarantined chain
89
+ _EXACT, _TAILS = _index(QUARANTINED)
90
+
91
+
92
+ def _is_quarantined(nodeid):
93
+ key = _key(nodeid)
94
+ if key is None:
95
+ return False
96
+ name, chain = key
97
+ # this chain is a tail of a quarantined one (the id was recorded with
98
+ # more leading directories) ...
99
+ if key in _TAILS:
100
+ return True
101
+ # ... or a quarantined chain is a tail of this one (it has fewer)
102
+ return any((name, chain[i:]) in _EXACT for i in range(len(chain)))
103
+
104
+
105
+ def _marker():
106
+ if MODE == "xfail":
107
+ return pytest.mark.xfail(reason=REASON, strict=False)
108
+ return pytest.mark.skip(reason=REASON)
109
+
110
+
111
+ def pytest_collection_modifyitems(items):
112
+ for item in items:
113
+ if _is_quarantined(item.nodeid):
114
+ item.add_marker(_marker())
115
+ '''
116
+
117
+
118
+ def write_pytest(out_dir: str, test_ids: list[str],
119
+ mode: str = "skip") -> list[str]:
120
+ if mode not in MODES:
121
+ raise ValueError(f"mode must be one of {MODES}, got {mode!r}")
122
+ os.makedirs(out_dir, exist_ok=True)
123
+ written = []
124
+ jp = os.path.join(out_dir, "flakebox_quarantine.json")
125
+ ids = sorted(set(test_ids))
126
+ with open(jp, "w", encoding="utf-8") as f:
127
+ # no timestamp: regenerating an unchanged set writes the same bytes
128
+ json.dump({"tests": ids, "mode": mode, "count": len(ids)}, f, indent=2)
129
+ f.write("\n")
130
+ written.append(jp)
131
+ pp = os.path.join(out_dir, "flakebox_quarantine.py")
132
+ with open(pp, "w", encoding="utf-8") as f:
133
+ f.write(PYTEST_PLUGIN)
134
+ written.append(pp)
135
+ return written
136
+
137
+
138
+ def pytest_nodeid(test_id: str) -> str:
139
+ """Map a JUnit id to the pytest node id it most likely came from.
140
+
141
+ pytest writes classname = dotted module path + class names and
142
+ name = the function (with any [params]). Modules are lowercase by
143
+ convention and classes start with a capital, so the leading run of
144
+ segments that do not start with an uppercase letter is the module:
145
+
146
+ pkg.module.Class::test_x -> pkg/module.py::Class::test_x
147
+ pkg.module.Outer.Inner::t -> pkg/module.py::Outer::Inner::t
148
+ pkg.module::test_x -> pkg/module.py::test_x
149
+ test_x (no classname) -> test_x (unchanged)
150
+
151
+ Best effort: a lowercase class name, a `python_files` pattern that
152
+ is not `*.py`, or a non-pytest producer breaks the guess, and pytest
153
+ simply ignores a --deselect that matches nothing. The generated
154
+ plugin (--format pytest) compares the name and the whole path
155
+ components instead and is the reliable path. Ids already in node-id
156
+ form (containing `/` or `.py::`) pass through unchanged.
157
+ """
158
+ if "::" not in test_id or "/" in test_id or ".py::" in test_id:
159
+ return test_id
160
+ classname, _, rest = test_id.partition("::")
161
+ segs = [s for s in classname.split(".") if s]
162
+ if not segs:
163
+ return test_id
164
+ i = 0
165
+ while i < len(segs) and not segs[i][:1].isupper():
166
+ i += 1
167
+ module, classes = segs[:i], segs[i:]
168
+ if not module: # classname is just class names: no module to place
169
+ return "::".join(classes + [rest])
170
+ return "::".join(["/".join(module) + ".py"] + classes + [rest])
171
+
172
+
173
+ def deselect_args(test_ids: list[str]) -> list[str]:
174
+ """`--deselect` args for pytest, with JUnit ids mapped to pytest node
175
+ ids by pytest_nodeid() (best effort - see there)."""
176
+ args = []
177
+ for tid in sorted(set(test_ids)):
178
+ args += ["--deselect", pytest_nodeid(tid)]
179
+ return args
180
+
181
+
182
+ def deselect_line(test_ids: list[str]) -> str:
183
+ """deselect_args() as one shell line. Each argument is quoted for the
184
+ local shell (POSIX quoting via shlex, cmd.exe double-quoting on
185
+ Windows), so the shell has to parse the line once more -
186
+ `eval "pytest $(flakebox quarantine --format deselect)"` - to hand
187
+ pytest an id with a space or shell characters (`test_x[a b]`,
188
+ `test_x[$HOME]`) as ONE argument. A bare `pytest $(...)` splits the
189
+ output at whitespace and keeps the quotes, so only ids with neither
190
+ survive it (the README also covers PowerShell)."""
191
+ args = deselect_args(test_ids)
192
+ if os.name == "nt":
193
+ return subprocess.list2cmdline(args)
194
+ return shlex.join(args)