flakehound 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.
- flakehound/__init__.py +3 -0
- flakehound/__main__.py +3 -0
- flakehound/adapters/__init__.py +0 -0
- flakehound/adapters/base.py +41 -0
- flakehound/adapters/pytest/__init__.py +1 -0
- flakehound/adapters/pytest/clock.py +135 -0
- flakehound/adapters/pytest/paths.py +53 -0
- flakehound/adapters/pytest/plugin.py +276 -0
- flakehound/adapters/pytest/runner.py +208 -0
- flakehound/adapters/pytest/snapshot.py +205 -0
- flakehound/adapters/pytest/timing.py +141 -0
- flakehound/cli.py +250 -0
- flakehound/cli_quarantine.py +157 -0
- flakehound/core/__init__.py +0 -0
- flakehound/core/executor.py +87 -0
- flakehound/core/models.py +156 -0
- flakehound/core/procs.py +127 -0
- flakehound/core/scheduler.py +57 -0
- flakehound/core/stats.py +22 -0
- flakehound/detectors/__init__.py +0 -0
- flakehound/detectors/probes.py +83 -0
- flakehound/detectors/shake.py +171 -0
- flakehound/diagnosis/__init__.py +1 -0
- flakehound/diagnosis/common.py +43 -0
- flakehound/diagnosis/ddmin.py +62 -0
- flakehound/diagnosis/env.py +295 -0
- flakehound/diagnosis/fixes.py +61 -0
- flakehound/diagnosis/order.py +256 -0
- flakehound/diagnosis/timing.py +263 -0
- flakehound/py.typed +0 -0
- flakehound/quarantine.py +112 -0
- flakehound/quarantine_plugin.py +49 -0
- flakehound/report/__init__.py +0 -0
- flakehound/report/findings.py +181 -0
- flakehound/report/json_report.py +16 -0
- flakehound/report/markdown.py +139 -0
- flakehound/report/redact.py +69 -0
- flakehound/report/terminal.py +277 -0
- flakehound/session.py +283 -0
- flakehound-0.1.0.dist-info/METADATA +210 -0
- flakehound-0.1.0.dist-info/RECORD +44 -0
- flakehound-0.1.0.dist-info/WHEEL +4 -0
- flakehound-0.1.0.dist-info/entry_points.txt +5 -0
- flakehound-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Runs pytest in subprocesses, one process per experiment.
|
|
2
|
+
|
|
3
|
+
Why a fresh process per run: every run starts from a clean interpreter, so state
|
|
4
|
+
can only leak *within* a run (which is what we want to observe), a crashing or
|
|
5
|
+
hanging test can't take flakehound down, and cleanup is reliable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
from xml.etree import ElementTree
|
|
19
|
+
|
|
20
|
+
from flakehound.adapters.base import CollectionError
|
|
21
|
+
from flakehound.core.models import Repro, RunResult, RunSpec, StateChange, TestResult
|
|
22
|
+
from flakehound.core.procs import ProcessRegistry
|
|
23
|
+
from flakehound.report.redact import redact, truncate
|
|
24
|
+
|
|
25
|
+
PLUGIN = "flakehound.adapters.pytest.plugin"
|
|
26
|
+
|
|
27
|
+
# Plugins that reorder tests would fight our ordering, and quarantine would hide
|
|
28
|
+
# the very failures we are hunting.
|
|
29
|
+
_BLOCKED_PLUGINS = ("randomly", "random_order", "cacheprovider", "flakehound-quarantine")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PytestRunner:
|
|
33
|
+
name = "pytest"
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
project_dir: Path,
|
|
38
|
+
pytest_args: Sequence[str] = (),
|
|
39
|
+
*,
|
|
40
|
+
python: str | None = None,
|
|
41
|
+
procs: ProcessRegistry,
|
|
42
|
+
timeout: float | None = 600.0,
|
|
43
|
+
base_env: Mapping[str, str] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.project_dir = project_dir
|
|
46
|
+
self.pytest_args = list(pytest_args)
|
|
47
|
+
self.python = python or sys.executable
|
|
48
|
+
self.procs = procs
|
|
49
|
+
self.timeout = timeout
|
|
50
|
+
self.base_env = dict(os.environ if base_env is None else base_env)
|
|
51
|
+
self._xdist: bool | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def has_xdist(self) -> bool:
|
|
55
|
+
"""Is pytest-xdist installed for the test interpreter? (checked once)
|
|
56
|
+
|
|
57
|
+
If so we pass ``-n 0``: a ``-n auto`` in the project's addopts would
|
|
58
|
+
otherwise spread tests over processes and break the exact orders we
|
|
59
|
+
run. (``-p no:xdist`` would make such addopts an error instead.)
|
|
60
|
+
"""
|
|
61
|
+
if self._xdist is None:
|
|
62
|
+
probe = (
|
|
63
|
+
"import importlib.util, sys; "
|
|
64
|
+
"sys.exit(0 if importlib.util.find_spec('xdist') else 1)"
|
|
65
|
+
)
|
|
66
|
+
res = self.procs.run(
|
|
67
|
+
[self.python, "-c", probe], cwd=self.project_dir, env=self._env(), timeout=60
|
|
68
|
+
)
|
|
69
|
+
self._xdist = res.returncode == 0 and not res.timed_out
|
|
70
|
+
return self._xdist
|
|
71
|
+
|
|
72
|
+
def _command(self, *extra: str) -> list[str]:
|
|
73
|
+
cmd = [self.python, "-m", "pytest", "-p", PLUGIN]
|
|
74
|
+
for plugin in _BLOCKED_PLUGINS:
|
|
75
|
+
cmd += ["-p", f"no:{plugin}"]
|
|
76
|
+
cmd += ["-q", "--no-header", "-o", "console_output_style=classic"]
|
|
77
|
+
if self.has_xdist:
|
|
78
|
+
cmd += ["-n", "0"]
|
|
79
|
+
cmd += list(extra)
|
|
80
|
+
cmd += self.pytest_args
|
|
81
|
+
return cmd
|
|
82
|
+
|
|
83
|
+
def _env(self, spec: RunSpec | None = None) -> dict[str, str]:
|
|
84
|
+
env = dict(self.base_env)
|
|
85
|
+
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
86
|
+
if spec is not None:
|
|
87
|
+
if spec.hash_seed is not None:
|
|
88
|
+
env["PYTHONHASHSEED"] = str(spec.hash_seed)
|
|
89
|
+
env.update(spec.env)
|
|
90
|
+
return env
|
|
91
|
+
|
|
92
|
+
def collect(self, workdir: Path) -> list[str]:
|
|
93
|
+
out = workdir / "collected.json"
|
|
94
|
+
res = self.procs.run(
|
|
95
|
+
self._command("--collect-only", f"--flakehound-collect-out={out}"),
|
|
96
|
+
cwd=self.project_dir,
|
|
97
|
+
env=self._env(),
|
|
98
|
+
timeout=self.timeout,
|
|
99
|
+
)
|
|
100
|
+
if res.timed_out:
|
|
101
|
+
raise CollectionError("pytest collection timed out")
|
|
102
|
+
# Exit code 5 = no tests collected, which is not an error for us.
|
|
103
|
+
if res.returncode not in (0, 5) or not out.exists():
|
|
104
|
+
detail = truncate(redact((res.stdout + "\n" + res.stderr).strip()), 4000)
|
|
105
|
+
raise CollectionError(f"pytest could not collect tests:\n{detail}")
|
|
106
|
+
ids = json.loads(out.read_text(encoding="utf-8"))
|
|
107
|
+
return [str(x) for x in ids]
|
|
108
|
+
|
|
109
|
+
def run(self, spec: RunSpec, workdir: Path) -> RunResult:
|
|
110
|
+
spec_path = workdir / f"spec-{spec.run_id}.json"
|
|
111
|
+
out_path = workdir / f"result-{spec.run_id}.json"
|
|
112
|
+
spec_path.write_text(json.dumps(spec.to_json()), encoding="utf-8")
|
|
113
|
+
start = time.perf_counter()
|
|
114
|
+
res = self.procs.run(
|
|
115
|
+
self._command(f"--flakehound-spec={spec_path}", f"--flakehound-out={out_path}"),
|
|
116
|
+
cwd=self.project_dir,
|
|
117
|
+
env=self._env(spec),
|
|
118
|
+
timeout=self.timeout,
|
|
119
|
+
)
|
|
120
|
+
duration = time.perf_counter() - start
|
|
121
|
+
results: list[TestResult] = []
|
|
122
|
+
state_changes: dict[str, list[StateChange]] = {}
|
|
123
|
+
sleep_sites: dict[str, list[dict[str, Any]]] = {}
|
|
124
|
+
if out_path.exists():
|
|
125
|
+
data = json.loads(out_path.read_text(encoding="utf-8"))
|
|
126
|
+
results = [TestResult.from_json(r) for r in data["results"]]
|
|
127
|
+
state_changes = {
|
|
128
|
+
str(nodeid): [StateChange.from_json(c) for c in changes]
|
|
129
|
+
for nodeid, changes in data.get("state_changes", {}).items()
|
|
130
|
+
}
|
|
131
|
+
sleep_sites = {str(k): list(v) for k, v in data.get("sleep_sites", {}).items()}
|
|
132
|
+
crashed = res.timed_out or not out_path.exists()
|
|
133
|
+
error = ""
|
|
134
|
+
if res.timed_out:
|
|
135
|
+
error = f"run timed out after {self.timeout:.0f}s"
|
|
136
|
+
elif crashed:
|
|
137
|
+
tail = "\n".join((res.stdout + "\n" + res.stderr).strip().splitlines()[-20:])
|
|
138
|
+
error = truncate(redact(f"worker exited with code {res.returncode}:\n{tail}"))
|
|
139
|
+
return RunResult(
|
|
140
|
+
spec, results, res.returncode, duration, crashed, error, state_changes, sleep_sites
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def repro_command(self, repro: Repro) -> list[str]:
|
|
144
|
+
"""A pytest command that reproduces a finding (env vars are listed separately).
|
|
145
|
+
|
|
146
|
+
pytest runs explicitly listed test ids in the order they are given.
|
|
147
|
+
Seed and clock replay need flakehound installed (they use its plugin).
|
|
148
|
+
"""
|
|
149
|
+
cmd = ["pytest", "-p", "no:randomly", "-p", "no:cacheprovider"]
|
|
150
|
+
if self.has_xdist:
|
|
151
|
+
cmd += ["-n", "0"] # keep the exact order even if addopts has -n auto
|
|
152
|
+
flags = []
|
|
153
|
+
if repro.seed is not None:
|
|
154
|
+
flags.append(f"--flakehound-seed={repro.seed}")
|
|
155
|
+
if repro.clock is not None:
|
|
156
|
+
flags.append(f"--flakehound-clock={repro.clock}")
|
|
157
|
+
if repro.sleep_scale is not None:
|
|
158
|
+
flags.append(f"--flakehound-sleep-scale={repro.sleep_scale:g}")
|
|
159
|
+
if repro.sleep_sites:
|
|
160
|
+
flags.append("--flakehound-sleep-sites=" + ",".join(repro.sleep_sites))
|
|
161
|
+
if repro.switch_interval is not None:
|
|
162
|
+
flags.append(f"--flakehound-switch-interval={repro.switch_interval:g}")
|
|
163
|
+
if flags:
|
|
164
|
+
cmd += ["-p", PLUGIN, *flags]
|
|
165
|
+
return [*cmd, *repro.order]
|
|
166
|
+
|
|
167
|
+
def check_repro(self, repro: Repro) -> bool:
|
|
168
|
+
"""Run :meth:`repro_command` for real; True if the last test failed.
|
|
169
|
+
|
|
170
|
+
This proves the exact command we print reproduces the failure. The last
|
|
171
|
+
test's outcome is read from a JUnit XML report (part of pytest itself).
|
|
172
|
+
"""
|
|
173
|
+
cmd = [self.python, "-m", *self.repro_command(repro)]
|
|
174
|
+
with tempfile.TemporaryDirectory(prefix="flakehound-repro-") as tmp:
|
|
175
|
+
xml = Path(tmp) / "repro.xml"
|
|
176
|
+
run_env = dict(self.base_env)
|
|
177
|
+
run_env["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
178
|
+
run_env.update(repro.env)
|
|
179
|
+
res = self.procs.run(
|
|
180
|
+
[*cmd, "-q", f"--junitxml={xml}"],
|
|
181
|
+
cwd=self.project_dir,
|
|
182
|
+
env=run_env,
|
|
183
|
+
timeout=self.timeout,
|
|
184
|
+
)
|
|
185
|
+
if res.timed_out or not xml.exists():
|
|
186
|
+
return False
|
|
187
|
+
return _last_test_failed(xml, repro.order[-1])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _last_test_failed(xml_path: Path, nodeid: str) -> bool:
|
|
191
|
+
"""Did ``nodeid`` fail in a pytest JUnit XML report?"""
|
|
192
|
+
# The XML is written by pytest from the user's own project, parsed with the
|
|
193
|
+
# stdlib parser, which does not resolve external entities.
|
|
194
|
+
tree = ElementTree.parse(xml_path) # noqa: S314
|
|
195
|
+
parts = nodeid.split("::")
|
|
196
|
+
name = parts[-1]
|
|
197
|
+
# JUnit classname is the dotted file path plus any test classes,
|
|
198
|
+
# e.g. "tests/test_a.py::TestX::test_y" -> "tests.test_a.TestX".
|
|
199
|
+
module = parts[0].removesuffix(".py").replace("/", ".").replace("\\", ".")
|
|
200
|
+
classname = ".".join([module, *parts[1:-1]])
|
|
201
|
+
for case in tree.iter("testcase"):
|
|
202
|
+
if (
|
|
203
|
+
case.get("name") == name
|
|
204
|
+
and case.get("classname") == classname
|
|
205
|
+
and (case.find("failure") is not None or case.find("error") is not None)
|
|
206
|
+
):
|
|
207
|
+
return True
|
|
208
|
+
return False
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Snapshot and diff process-global state around one test.
|
|
2
|
+
|
|
3
|
+
Used to name *what* a polluter test leaked: "test_add_item leaves
|
|
4
|
+
shop.cart = ['apple']" is far more useful than "test_add_item leaks state".
|
|
5
|
+
|
|
6
|
+
What we look at (cheap, read-only):
|
|
7
|
+
* module-level variables of the project's own modules (not site-packages),
|
|
8
|
+
one level into plain objects and class attributes;
|
|
9
|
+
* ``os.environ``, the current directory, ``sys.path``;
|
|
10
|
+
* files in the project directory (name, size, mtime);
|
|
11
|
+
* the root logger's level and handlers, and ``logging.disable``.
|
|
12
|
+
|
|
13
|
+
Limits, stated honestly: state hidden in fixtures, closures, C extensions,
|
|
14
|
+
databases or external services is invisible here.
|
|
15
|
+
|
|
16
|
+
Every value is passed through :func:`redact` and truncated, and environment
|
|
17
|
+
variables with secret-sounding names never show their values.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import reprlib
|
|
26
|
+
import sys
|
|
27
|
+
import types
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from flakehound.adapters.pytest.paths import canon, is_project_file
|
|
33
|
+
from flakehound.core.models import StateChange
|
|
34
|
+
from flakehound.report.redact import REDACTED, redact
|
|
35
|
+
|
|
36
|
+
MISSING = "<missing>"
|
|
37
|
+
_MAX_VALUE = 120
|
|
38
|
+
_MAX_ATTRS = 50
|
|
39
|
+
_MAX_FILES = 20_000
|
|
40
|
+
_MAX_CHANGES = 25
|
|
41
|
+
_SKIP_DIRS = {
|
|
42
|
+
".git",
|
|
43
|
+
".hg",
|
|
44
|
+
".svn",
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
"env",
|
|
48
|
+
".env",
|
|
49
|
+
"node_modules",
|
|
50
|
+
"__pycache__",
|
|
51
|
+
".pytest_cache",
|
|
52
|
+
".mypy_cache",
|
|
53
|
+
".ruff_cache",
|
|
54
|
+
".tox",
|
|
55
|
+
".nox",
|
|
56
|
+
"build",
|
|
57
|
+
"dist",
|
|
58
|
+
".eggs",
|
|
59
|
+
}
|
|
60
|
+
_SKIP_MODULE_PREFIXES = ("flakehound", "_pytest", "pytest", "pluggy", "py.")
|
|
61
|
+
# Set and cleared by pytest itself around every test: never "leaked state".
|
|
62
|
+
_IGNORED_ENV = {"PYTEST_CURRENT_TEST"}
|
|
63
|
+
_SECRET_ENV_NAME = re.compile(r"(?i)(pass|secret|token|key|auth|credential|cookie|session)")
|
|
64
|
+
|
|
65
|
+
_repr = reprlib.Repr()
|
|
66
|
+
_repr.maxstring = 60
|
|
67
|
+
_repr.maxother = 60
|
|
68
|
+
_repr.maxlist = _repr.maxtuple = _repr.maxset = _repr.maxfrozenset = _repr.maxdeque = 8
|
|
69
|
+
_repr.maxdict = 8
|
|
70
|
+
_repr.maxlevel = 3
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def safe_repr(value: object) -> str:
|
|
74
|
+
try:
|
|
75
|
+
text = _repr.repr(value)
|
|
76
|
+
except Exception: # a user __repr__ may raise anything
|
|
77
|
+
text = f"<{type(value).__name__} (repr failed)>"
|
|
78
|
+
try:
|
|
79
|
+
size = len(value) # type: ignore[arg-type]
|
|
80
|
+
except Exception:
|
|
81
|
+
size = None
|
|
82
|
+
# Truncated containers can hide in-place changes: include the length.
|
|
83
|
+
if size is not None and isinstance(value, (list, dict, set, tuple, frozenset)):
|
|
84
|
+
text = f"{text} (len {size})" if size > _repr.maxlist else text
|
|
85
|
+
return redact(text if len(text) <= _MAX_VALUE else text[: _MAX_VALUE - 3] + "...")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Snapshot:
|
|
90
|
+
values: dict[tuple[str, str], str] = field(default_factory=dict) # (kind, name) -> repr
|
|
91
|
+
files: dict[str, tuple[int, int]] = field(default_factory=dict) # path -> (size, mtime)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_project_module(name: str, module: types.ModuleType, root: str) -> bool:
|
|
95
|
+
if name.startswith(_SKIP_MODULE_PREFIXES) or name == "__main__":
|
|
96
|
+
return False
|
|
97
|
+
file = getattr(module, "__file__", None)
|
|
98
|
+
return bool(file) and is_project_file(str(file), root)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _plain_attrs(obj: object) -> Iterator[tuple[str, object]]:
|
|
102
|
+
try:
|
|
103
|
+
attrs = vars(obj)
|
|
104
|
+
except TypeError:
|
|
105
|
+
return
|
|
106
|
+
for i, (key, value) in enumerate(list(attrs.items())):
|
|
107
|
+
if i >= _MAX_ATTRS:
|
|
108
|
+
break
|
|
109
|
+
if key.startswith("__") or callable(value) or isinstance(value, types.ModuleType):
|
|
110
|
+
continue
|
|
111
|
+
yield key, value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _module_state(root: Path) -> Iterator[tuple[str, str, str]]:
|
|
115
|
+
root_s = canon(str(root))
|
|
116
|
+
for mod_name, module in list(sys.modules.items()):
|
|
117
|
+
if module is None or not _is_project_module(mod_name, module, root_s):
|
|
118
|
+
continue
|
|
119
|
+
for attr, value in list(vars(module).items()):
|
|
120
|
+
if attr.startswith("__") or isinstance(value, types.ModuleType):
|
|
121
|
+
continue
|
|
122
|
+
if isinstance(value, type):
|
|
123
|
+
if getattr(value, "__module__", None) != mod_name:
|
|
124
|
+
continue # imported class: owned by another module
|
|
125
|
+
for key, cval in _plain_attrs(value):
|
|
126
|
+
yield "class_attr", f"{mod_name}.{attr}.{key}", safe_repr(cval)
|
|
127
|
+
continue
|
|
128
|
+
if callable(value):
|
|
129
|
+
continue # functions, and objects like mocks are caught via repr below
|
|
130
|
+
yield "global", f"{mod_name}.{attr}", safe_repr(value)
|
|
131
|
+
# One level into plain objects: catches app.state.CURRENCY = "EUR".
|
|
132
|
+
if not isinstance(value, (str, bytes, int, float, bool, list, dict, set, tuple)):
|
|
133
|
+
for key, aval in _plain_attrs(value):
|
|
134
|
+
yield "global", f"{mod_name}.{attr}.{key}", safe_repr(aval)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _files(root: Path) -> dict[str, tuple[int, int]]:
|
|
138
|
+
out: dict[str, tuple[int, int]] = {}
|
|
139
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
140
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not d.endswith(".egg-info")]
|
|
141
|
+
for name in filenames:
|
|
142
|
+
if name.endswith((".pyc", ".pyo")):
|
|
143
|
+
continue
|
|
144
|
+
path = Path(dirpath, name)
|
|
145
|
+
try:
|
|
146
|
+
st = path.stat()
|
|
147
|
+
except OSError:
|
|
148
|
+
continue
|
|
149
|
+
out[path.relative_to(root).as_posix()] = (st.st_size, st.st_mtime_ns)
|
|
150
|
+
if len(out) >= _MAX_FILES:
|
|
151
|
+
return out
|
|
152
|
+
return out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _env_value(name: str, value: str) -> str:
|
|
156
|
+
if _SECRET_ENV_NAME.search(name):
|
|
157
|
+
return REDACTED
|
|
158
|
+
return safe_repr(value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def take(root: Path) -> Snapshot:
|
|
162
|
+
snap = Snapshot()
|
|
163
|
+
v = snap.values
|
|
164
|
+
for kind, name, rep in _module_state(root):
|
|
165
|
+
v[(kind, name)] = rep
|
|
166
|
+
for key, value in os.environ.items():
|
|
167
|
+
if key not in _IGNORED_ENV:
|
|
168
|
+
v[("env", key)] = _env_value(key, value)
|
|
169
|
+
try:
|
|
170
|
+
v[("cwd", "current directory")] = redact(os.getcwd())
|
|
171
|
+
except OSError:
|
|
172
|
+
v[("cwd", "current directory")] = "<deleted>"
|
|
173
|
+
v[("sys_path", "sys.path")] = safe_repr(list(sys.path))
|
|
174
|
+
root_logger = logging.getLogger()
|
|
175
|
+
v[("logging", "root logger level")] = logging.getLevelName(root_logger.level)
|
|
176
|
+
v[("logging", "root logger handlers")] = safe_repr(
|
|
177
|
+
[type(h).__name__ for h in root_logger.handlers]
|
|
178
|
+
)
|
|
179
|
+
v[("logging", "logging.disable")] = str(logging.root.manager.disable)
|
|
180
|
+
snap.files = _files(root)
|
|
181
|
+
return snap
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def diff(before: Snapshot, after: Snapshot) -> list[StateChange]:
|
|
185
|
+
changes: list[StateChange] = []
|
|
186
|
+
for key in sorted(set(before.values) | set(after.values)):
|
|
187
|
+
old = before.values.get(key, MISSING)
|
|
188
|
+
new = after.values.get(key, MISSING)
|
|
189
|
+
if old != new:
|
|
190
|
+
changes.append(StateChange(key[0], key[1], old, new))
|
|
191
|
+
for path in sorted(set(before.files) | set(after.files)):
|
|
192
|
+
old_f = before.files.get(path)
|
|
193
|
+
new_f = after.files.get(path)
|
|
194
|
+
if old_f == new_f:
|
|
195
|
+
continue
|
|
196
|
+
if old_f is None:
|
|
197
|
+
changes.append(StateChange("file", path, MISSING, "created"))
|
|
198
|
+
elif new_f is None:
|
|
199
|
+
changes.append(StateChange("file", path, "existed", "deleted"))
|
|
200
|
+
else:
|
|
201
|
+
changes.append(StateChange("file", path, "existed", "modified"))
|
|
202
|
+
# Most useful first: globals, then env, then the rest.
|
|
203
|
+
rank = {"global": 0, "class_attr": 1, "env": 2, "cwd": 3, "file": 4, "sys_path": 5}
|
|
204
|
+
changes.sort(key=lambda c: (rank.get(c.kind, 9), c.name))
|
|
205
|
+
return changes[:_MAX_CHANGES]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Timing stress inside a worker: shorter waits and frequent thread switches.
|
|
2
|
+
|
|
3
|
+
* **Sleep shrinking.** ``time.sleep`` is wrapped. Sleeps made on the *main
|
|
4
|
+
thread* (where the test waits) can be scaled down, either all of them or only
|
|
5
|
+
the ones at given call sites (``path:line``). Sleeps in background threads
|
|
6
|
+
(which usually simulate work) are left alone. If a test fails when its wait
|
|
7
|
+
is shortened, it was using ``sleep`` to wait for something that is not
|
|
8
|
+
guaranteed to be done: on a slower machine it will fail the same way.
|
|
9
|
+
* **Call-site recording.** Every main-thread sleep is attributed to the first
|
|
10
|
+
stack frame in the project's own code, so we can say *which* sleep matters.
|
|
11
|
+
* **Thread-switch stress.** ``sys.setswitchinterval`` makes the interpreter
|
|
12
|
+
switch threads far more often, and a profile hook briefly yields the GIL
|
|
13
|
+
every time any thread enters a function of the project's own code. The
|
|
14
|
+
yield matters on systems (like macOS) where a waiting thread wakes up too
|
|
15
|
+
slowly for the switch interval alone to interleave short critical sections.
|
|
16
|
+
Together they expose races that are rare by default.
|
|
17
|
+
|
|
18
|
+
``time.monotonic``, ``perf_counter`` and ``asyncio.sleep`` are not touched.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time as _time
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from types import FrameType
|
|
30
|
+
|
|
31
|
+
from flakehound.adapters.pytest.paths import canon, is_project_file, real
|
|
32
|
+
|
|
33
|
+
_real_sleep: Callable[[float], None] = _time.sleep
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class SleepSite:
|
|
38
|
+
site: str # "tests/test_upload.py:9"
|
|
39
|
+
seconds: float # longest requested sleep at this site
|
|
40
|
+
count: int = 0
|
|
41
|
+
|
|
42
|
+
def to_json(self) -> dict[str, object]:
|
|
43
|
+
return {"site": self.site, "seconds": round(self.seconds, 6), "count": self.count}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class _State:
|
|
48
|
+
root: str = ""
|
|
49
|
+
scale: float | None = None
|
|
50
|
+
only_sites: set[str] | None = None
|
|
51
|
+
current_test: str | None = None
|
|
52
|
+
sites: dict[str, dict[str, SleepSite]] = field(default_factory=dict) # test -> site -> info
|
|
53
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_state = _State()
|
|
57
|
+
_root_real = "" # resolved root, original case (for display)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _project_site(frame: FrameType | None) -> str | None:
|
|
61
|
+
"""``relative/path.py:line`` of the first frame in the project's own code."""
|
|
62
|
+
while frame is not None:
|
|
63
|
+
file = frame.f_code.co_filename
|
|
64
|
+
if is_project_file(file, _state.root):
|
|
65
|
+
rel = os.path.relpath(real(file), _root_real)
|
|
66
|
+
return f"{rel.replace(os.sep, '/')}:{frame.f_lineno}"
|
|
67
|
+
frame = frame.f_back
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _sleep(seconds: float) -> None:
|
|
72
|
+
if threading.current_thread() is threading.main_thread() and _state.current_test:
|
|
73
|
+
site = _project_site(sys._getframe(1))
|
|
74
|
+
if site is not None:
|
|
75
|
+
with _state.lock:
|
|
76
|
+
per_test = _state.sites.setdefault(_state.current_test, {})
|
|
77
|
+
info = per_test.setdefault(site, SleepSite(site, 0.0))
|
|
78
|
+
info.count += 1
|
|
79
|
+
info.seconds = max(info.seconds, float(seconds))
|
|
80
|
+
if _state.scale is not None and (
|
|
81
|
+
_state.only_sites is None or site in _state.only_sites
|
|
82
|
+
):
|
|
83
|
+
seconds = seconds * _state.scale
|
|
84
|
+
_real_sleep(seconds)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def install(
|
|
88
|
+
root: str,
|
|
89
|
+
*,
|
|
90
|
+
scale: float | None = None,
|
|
91
|
+
only_sites: list[str] | None = None,
|
|
92
|
+
switch_interval: float | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Patch ``time.sleep`` (recording always; shrinking if ``scale``)."""
|
|
95
|
+
global _root_real
|
|
96
|
+
_state.root = canon(root)
|
|
97
|
+
_root_real = real(root)
|
|
98
|
+
_state.scale = scale
|
|
99
|
+
_state.only_sites = set(only_sites) if only_sites else None
|
|
100
|
+
if _time.sleep is not _sleep:
|
|
101
|
+
_time.sleep = _sleep # type: ignore[assignment]
|
|
102
|
+
_rebind_project_modules()
|
|
103
|
+
if switch_interval is not None:
|
|
104
|
+
sys.setswitchinterval(switch_interval)
|
|
105
|
+
threading.setprofile(_yield_on_call)
|
|
106
|
+
sys.setprofile(_yield_on_call)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _rebind_project_modules() -> None:
|
|
110
|
+
"""``from time import sleep`` in already-imported project modules (e.g. conftest)."""
|
|
111
|
+
for module in list(sys.modules.values()):
|
|
112
|
+
file = getattr(module, "__file__", None)
|
|
113
|
+
if not file or not is_project_file(file, _state.root):
|
|
114
|
+
continue
|
|
115
|
+
for name, value in list(vars(module).items()):
|
|
116
|
+
if value is _real_sleep:
|
|
117
|
+
setattr(module, name, _sleep)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_project_code: dict[str, bool] = {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _yield_on_call(frame: FrameType, event: str, arg: object) -> None:
|
|
124
|
+
"""Profile hook: let other threads run whenever project code calls a function."""
|
|
125
|
+
if event != "call":
|
|
126
|
+
return
|
|
127
|
+
file = frame.f_code.co_filename
|
|
128
|
+
mine = _project_code.get(file)
|
|
129
|
+
if mine is None:
|
|
130
|
+
mine = _project_code[file] = is_project_file(file, _state.root)
|
|
131
|
+
if mine:
|
|
132
|
+
_real_sleep(1e-6) # releases the GIL; a waiting thread can take over
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def set_current_test(nodeid: str | None) -> None:
|
|
136
|
+
_state.current_test = nodeid
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def sites_for(nodeid: str) -> list[SleepSite]:
|
|
140
|
+
with _state.lock:
|
|
141
|
+
return sorted(_state.sites.get(nodeid, {}).values(), key=lambda s: s.site)
|