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/__init__.py +0 -0
- flakebox/__main__.py +4 -0
- flakebox/cli.py +475 -0
- flakebox/detect.py +175 -0
- flakebox/junit.py +139 -0
- flakebox/quarantine.py +194 -0
- flakebox/store.py +363 -0
- flakebox-0.4.4.dist-info/METADATA +365 -0
- flakebox-0.4.4.dist-info/RECORD +13 -0
- flakebox-0.4.4.dist-info/WHEEL +5 -0
- flakebox-0.4.4.dist-info/entry_points.txt +2 -0
- flakebox-0.4.4.dist-info/licenses/LICENSE +21 -0
- flakebox-0.4.4.dist-info/top_level.txt +1 -0
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)
|