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.
Files changed (44) hide show
  1. flakehound/__init__.py +3 -0
  2. flakehound/__main__.py +3 -0
  3. flakehound/adapters/__init__.py +0 -0
  4. flakehound/adapters/base.py +41 -0
  5. flakehound/adapters/pytest/__init__.py +1 -0
  6. flakehound/adapters/pytest/clock.py +135 -0
  7. flakehound/adapters/pytest/paths.py +53 -0
  8. flakehound/adapters/pytest/plugin.py +276 -0
  9. flakehound/adapters/pytest/runner.py +208 -0
  10. flakehound/adapters/pytest/snapshot.py +205 -0
  11. flakehound/adapters/pytest/timing.py +141 -0
  12. flakehound/cli.py +250 -0
  13. flakehound/cli_quarantine.py +157 -0
  14. flakehound/core/__init__.py +0 -0
  15. flakehound/core/executor.py +87 -0
  16. flakehound/core/models.py +156 -0
  17. flakehound/core/procs.py +127 -0
  18. flakehound/core/scheduler.py +57 -0
  19. flakehound/core/stats.py +22 -0
  20. flakehound/detectors/__init__.py +0 -0
  21. flakehound/detectors/probes.py +83 -0
  22. flakehound/detectors/shake.py +171 -0
  23. flakehound/diagnosis/__init__.py +1 -0
  24. flakehound/diagnosis/common.py +43 -0
  25. flakehound/diagnosis/ddmin.py +62 -0
  26. flakehound/diagnosis/env.py +295 -0
  27. flakehound/diagnosis/fixes.py +61 -0
  28. flakehound/diagnosis/order.py +256 -0
  29. flakehound/diagnosis/timing.py +263 -0
  30. flakehound/py.typed +0 -0
  31. flakehound/quarantine.py +112 -0
  32. flakehound/quarantine_plugin.py +49 -0
  33. flakehound/report/__init__.py +0 -0
  34. flakehound/report/findings.py +181 -0
  35. flakehound/report/json_report.py +16 -0
  36. flakehound/report/markdown.py +139 -0
  37. flakehound/report/redact.py +69 -0
  38. flakehound/report/terminal.py +277 -0
  39. flakehound/session.py +283 -0
  40. flakehound-0.1.0.dist-info/METADATA +210 -0
  41. flakehound-0.1.0.dist-info/RECORD +44 -0
  42. flakehound-0.1.0.dist-info/WHEEL +4 -0
  43. flakehound-0.1.0.dist-info/entry_points.txt +5 -0
  44. 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)