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
flakehound/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """flakehound: find flaky tests and prove why they're flaky."""
2
+
3
+ __version__ = "0.1.0"
flakehound/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from flakehound.cli import main
2
+
3
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,41 @@
1
+ """The adapter interface every test runner implements.
2
+
3
+ The core engine only talks to this interface, so supporting Jest, Go test or
4
+ JUnit later means writing a new adapter, not changing the engine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Protocol
11
+
12
+ from flakehound.core.models import Repro, RunResult, RunSpec
13
+
14
+
15
+ class CollectionError(RuntimeError):
16
+ """The runner could not list the tests (syntax error, bad path, ...)."""
17
+
18
+
19
+ class Runner(Protocol):
20
+ name: str
21
+
22
+ def collect(self, workdir: Path) -> list[str]:
23
+ """Return the ids of all tests, in the runner's default order."""
24
+ ...
25
+
26
+ def run(self, spec: RunSpec, workdir: Path) -> RunResult:
27
+ """Run ``spec.order`` exactly in that order, under ``spec``'s conditions.
28
+
29
+ ``workdir`` is a private temp directory the adapter may use for
30
+ exchange files. Must never raise for test failures; a crashed or timed
31
+ out run is reported with ``crashed=True``.
32
+ """
33
+ ...
34
+
35
+ def repro_command(self, repro: Repro) -> list[str]:
36
+ """A command the user can copy to reproduce a finding (env vars separate)."""
37
+ ...
38
+
39
+ def check_repro(self, repro: Repro) -> bool:
40
+ """Run :meth:`repro_command` for real; True if the last test failed."""
41
+ ...
@@ -0,0 +1 @@
1
+ """pytest adapter: runs pytest subprocesses with the flakehound worker plugin."""
@@ -0,0 +1,135 @@
1
+ """A shifted wall clock: make "now" read a chosen date and time, then keep ticking.
2
+
3
+ Why shifted and not frozen: frozen time hangs code that waits for the clock to
4
+ move (polling loops, timeouts). A shifted clock moves normally, it just starts at
5
+ a different instant, like running the suite on another day.
6
+
7
+ What is shifted: ``time.time``/``time_ns``, ``time.localtime``/``gmtime``/
8
+ ``ctime``/``asctime``/``strftime`` when called without an explicit time, and
9
+ ``datetime.datetime.now/utcnow/today`` plus ``datetime.date.today``.
10
+ What is *not* shifted (on purpose): ``time.monotonic``, ``perf_counter`` and
11
+ ``sleep``, so timeouts and durations stay real; file timestamps; C extensions.
12
+
13
+ Installed in the worker before tests are imported, so ``from datetime import
14
+ datetime`` in test and project code picks up the shifted class.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as _dt
20
+ import sys
21
+ import time as _time
22
+ from collections.abc import Callable
23
+ from typing import Any
24
+
25
+ from flakehound.adapters.pytest.paths import canon, is_project_file
26
+
27
+ _real_datetime = _dt.datetime
28
+ _real_date = _dt.date
29
+ _real_time = _time.time
30
+ _real_time_ns = _time.time_ns
31
+ _real_localtime = _time.localtime
32
+ _real_gmtime = _time.gmtime
33
+ _real_ctime = _time.ctime
34
+ _real_asctime = _time.asctime
35
+ _real_strftime = _time.strftime
36
+
37
+ _offset = 0.0
38
+ _installed = False
39
+
40
+
41
+ def _now() -> float:
42
+ return _real_time() + _offset
43
+
44
+
45
+ class _DatetimeMeta(type):
46
+ def __instancecheck__(cls, obj: object) -> bool:
47
+ return isinstance(obj, _real_datetime)
48
+
49
+ def __subclasscheck__(cls, sub: type) -> bool:
50
+ return issubclass(sub, _real_datetime)
51
+
52
+
53
+ class _DateMeta(type):
54
+ def __instancecheck__(cls, obj: object) -> bool:
55
+ return isinstance(obj, _real_date)
56
+
57
+ def __subclasscheck__(cls, sub: type) -> bool:
58
+ return issubclass(sub, _real_date)
59
+
60
+
61
+ class ShiftedDatetime(_real_datetime, metaclass=_DatetimeMeta):
62
+ @classmethod
63
+ def now(cls, tz: _dt.tzinfo | None = None) -> _dt.datetime: # type: ignore[override]
64
+ return _real_datetime.fromtimestamp(_now(), tz)
65
+
66
+ @classmethod
67
+ def utcnow(cls) -> _dt.datetime: # type: ignore[override]
68
+ return _real_datetime.fromtimestamp(_now(), _dt.timezone.utc).replace(tzinfo=None)
69
+
70
+ @classmethod
71
+ def today(cls) -> _dt.datetime: # type: ignore[override]
72
+ return _real_datetime.fromtimestamp(_now())
73
+
74
+
75
+ class ShiftedDate(_real_date, metaclass=_DateMeta):
76
+ @classmethod
77
+ def today(cls) -> _dt.date: # type: ignore[override]
78
+ return _real_date.fromtimestamp(_now())
79
+
80
+
81
+ def _wrap_optional_time(real: Callable[..., Any]) -> Callable[..., Any]:
82
+ def wrapper(secs: float | None = None) -> Any:
83
+ return real(_now() if secs is None else secs)
84
+
85
+ wrapper.__name__ = getattr(real, "__name__", "wrapper")
86
+ return wrapper
87
+
88
+
89
+ def _asctime(t: Any = None) -> str:
90
+ return _real_asctime(_real_localtime(_now()) if t is None else t)
91
+
92
+
93
+ def _strftime(fmt: str, t: Any = None) -> str:
94
+ return _real_strftime(fmt, _real_localtime(_now()) if t is None else t)
95
+
96
+
97
+ def parse_target(text: str) -> _dt.datetime:
98
+ """``2026-12-31T23:59:58`` (local wall time) -> naive datetime."""
99
+ return _real_datetime.fromisoformat(text).replace(tzinfo=None)
100
+
101
+
102
+ def install(target: str, project_root: str | None = None) -> None:
103
+ """Shift the clock so local wall time reads ``target`` right now."""
104
+ global _offset, _installed
105
+ wanted = parse_target(target).timestamp() # naive -> interpreted as local time
106
+ _offset = wanted - _real_time()
107
+ if _installed:
108
+ return
109
+ _installed = True
110
+ _time.time = _now
111
+ _time.time_ns = lambda: _real_time_ns() + int(_offset * 1e9)
112
+ _time.localtime = _wrap_optional_time(_real_localtime)
113
+ _time.gmtime = _wrap_optional_time(_real_gmtime)
114
+ _time.ctime = _wrap_optional_time(_real_ctime)
115
+ _time.asctime = _asctime
116
+ _time.strftime = _strftime
117
+ _dt.datetime = ShiftedDatetime # type: ignore[misc]
118
+ _dt.date = ShiftedDate # type: ignore[misc]
119
+ if project_root:
120
+ _rebind_project_modules(project_root)
121
+
122
+
123
+ def _rebind_project_modules(project_root: str) -> None:
124
+ """Replace ``from datetime import datetime`` bindings in already-imported
125
+ project modules (e.g. conftest.py). Library modules are left alone."""
126
+ root = canon(project_root)
127
+ swap = {id(_real_datetime): ShiftedDatetime, id(_real_date): ShiftedDate}
128
+ for module in list(sys.modules.values()):
129
+ file = getattr(module, "__file__", None)
130
+ if not file or not is_project_file(file, root):
131
+ continue
132
+ for name, value in list(vars(module).items()):
133
+ replacement = swap.get(id(value))
134
+ if replacement is not None:
135
+ setattr(module, name, replacement)
@@ -0,0 +1,53 @@
1
+ """Path helpers shared by the worker hooks.
2
+
3
+ Paths are compared after resolving symlinks: on macOS the temp folder
4
+ ``/var/...`` is a symlink to ``/private/var/...``, so a test file's path and the
5
+ project root can look different while being the same place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import os
12
+ import sys
13
+ import sysconfig
14
+
15
+
16
+ @functools.lru_cache(maxsize=4096)
17
+ def real(path: str) -> str:
18
+ """Absolute path with symlinks resolved (keeps the original letter case)."""
19
+ return os.path.realpath(path)
20
+
21
+
22
+ @functools.lru_cache(maxsize=4096)
23
+ def canon(path: str) -> str:
24
+ """Comparable form: resolved and case-normalized (lowercase on Windows)."""
25
+ return os.path.normcase(real(path))
26
+
27
+
28
+ def is_under(path: str, root: str) -> bool:
29
+ """True if ``path`` is ``root`` or inside it. Both must be ``canon()`` forms."""
30
+ return path == root or path.startswith(root.rstrip(os.sep) + os.sep)
31
+
32
+
33
+ @functools.lru_cache(maxsize=1)
34
+ def library_roots() -> tuple[str, ...]:
35
+ """Where installed packages and the standard library live (canon form)."""
36
+ paths = {sys.prefix, sys.base_prefix, sys.exec_prefix}
37
+ known = sysconfig.get_paths()
38
+ for key in ("purelib", "platlib", "stdlib", "platstdlib"):
39
+ if key in known:
40
+ paths.add(known[key])
41
+ return tuple(canon(p) for p in paths if p)
42
+
43
+
44
+ # flakehound's own package directory: never treated as project code.
45
+ OWN_PACKAGE = canon(os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
46
+
47
+
48
+ def is_project_file(file: str, root: str) -> bool:
49
+ """``file`` belongs to the user's project (``root`` in canon form)."""
50
+ path = canon(file)
51
+ if not is_under(path, root) or is_under(path, OWN_PACKAGE):
52
+ return False
53
+ return not any(is_under(path, lib) for lib in library_roots())
@@ -0,0 +1,276 @@
1
+ """pytest plugin loaded *inside* each worker process (``-p flakehound.adapters.pytest.plugin``).
2
+
3
+ It is deliberately not registered as an auto-loaded ``pytest11`` entry point:
4
+ installing flakehound must never change how your normal ``pytest`` runs behave.
5
+
6
+ What it does, only when flakehound passes its options:
7
+ * keeps exactly the tests in the run spec, in the spec's order;
8
+ * seeds ``random`` per test from the spec seed (deterministic and independent of order);
9
+ * writes one JSON result per test (redacted failure message included);
10
+ * snapshots global state around the tests listed in ``spec.snapshot``;
11
+ * shifts the wall clock when the spec asks for it;
12
+ * shortens main-thread sleeps / stresses thread switching when asked (timing.py).
13
+
14
+ Two options are also meant for humans, to replay what flakehound found with
15
+ plain pytest: ``--flakehound-seed=N`` (seed ``random`` per test exactly like a
16
+ flakehound run) and ``--flakehound-clock=2026-12-31T23:59:58`` (shift the clock).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import random
26
+ import sys
27
+ from collections.abc import Generator
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ import pytest
32
+
33
+ from flakehound.adapters.pytest import clock, snapshot, timing
34
+ from flakehound.core.models import Outcome, RunSpec, StateChange, TestResult
35
+ from flakehound.report.redact import redact, truncate
36
+
37
+ _MESSAGE_LINES = 25
38
+
39
+
40
+ def pytest_addoption(parser: pytest.Parser) -> None:
41
+ group = parser.getgroup("flakehound", "flakehound worker (internal)")
42
+ group.addoption("--flakehound-spec", default=None, help="internal: run spec JSON")
43
+ group.addoption("--flakehound-out", default=None, help="internal: results JSON")
44
+ group.addoption("--flakehound-collect-out", default=None, help="internal: collected ids JSON")
45
+ group.addoption(
46
+ "--flakehound-seed",
47
+ type=int,
48
+ default=None,
49
+ help="seed `random` per test like flakehound does (to replay a finding)",
50
+ )
51
+ group.addoption(
52
+ "--flakehound-clock",
53
+ default=None,
54
+ metavar="ISO_DATETIME",
55
+ help="make the wall clock start at this local time, e.g. 2026-12-31T23:59:58",
56
+ )
57
+ group.addoption(
58
+ "--flakehound-sleep-scale",
59
+ type=float,
60
+ default=None,
61
+ metavar="FACTOR",
62
+ help="shorten time.sleep() calls made by tests on the main thread by this factor",
63
+ )
64
+ group.addoption(
65
+ "--flakehound-sleep-sites",
66
+ default=None,
67
+ metavar="PATH:LINE,...",
68
+ help="only shorten sleeps called from these source lines",
69
+ )
70
+ group.addoption(
71
+ "--flakehound-switch-interval",
72
+ type=float,
73
+ default=None,
74
+ metavar="SECONDS",
75
+ help="thread switch interval (e.g. 1e-6) to expose race conditions",
76
+ )
77
+
78
+
79
+ class _Worker:
80
+ def __init__(
81
+ self,
82
+ spec: RunSpec | None,
83
+ out: Path | None,
84
+ collect_out: Path | None,
85
+ seed: int | None = None,
86
+ ) -> None:
87
+ self.spec = spec
88
+ self.seed = spec.seed if spec is not None else seed
89
+ self.out = out
90
+ self.collect_out = collect_out
91
+ self.results: dict[str, TestResult] = {}
92
+ self.state_changes: dict[str, list[StateChange]] = {}
93
+
94
+ # --- ordering ---------------------------------------------------------
95
+ @pytest.hookimpl(trylast=True)
96
+ def pytest_collection_modifyitems(
97
+ self, session: pytest.Session, config: pytest.Config, items: list[pytest.Item]
98
+ ) -> None:
99
+ if self.spec is None:
100
+ return
101
+ position = {nodeid: i for i, nodeid in enumerate(self.spec.order)}
102
+ keep = [it for it in items if it.nodeid in position]
103
+ drop = [it for it in items if it.nodeid not in position]
104
+ keep.sort(key=lambda it: position[it.nodeid])
105
+ if drop:
106
+ config.hook.pytest_deselected(items=drop)
107
+ items[:] = keep
108
+
109
+ def pytest_collection_finish(self, session: pytest.Session) -> None:
110
+ if self.collect_out is not None:
111
+ _write_json(self.collect_out, [item.nodeid for item in session.items])
112
+
113
+ # --- determinism ------------------------------------------------------
114
+ @pytest.hookimpl(tryfirst=True)
115
+ def pytest_runtest_setup(self, item: pytest.Item) -> None:
116
+ if self.seed is not None:
117
+ seed_for_test(self.seed, item.nodeid)
118
+
119
+ # --- state snapshots ----------------------------------------------------
120
+ @pytest.hookimpl(hookwrapper=True)
121
+ def pytest_runtest_protocol(
122
+ self, item: pytest.Item, nextitem: pytest.Item | None
123
+ ) -> Generator[None, None, None]:
124
+ timing.set_current_test(item.nodeid)
125
+ try:
126
+ watch = self.spec is not None and item.nodeid in self.spec.snapshot
127
+ if not watch:
128
+ yield
129
+ return
130
+ root = Path(str(item.config.rootpath))
131
+ before = snapshot.take(root)
132
+ yield # setup, call and teardown of this test
133
+ after = snapshot.take(root)
134
+ self.state_changes[item.nodeid] = snapshot.diff(before, after)
135
+ finally:
136
+ timing.set_current_test(None)
137
+
138
+ # --- results ----------------------------------------------------------
139
+ def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
140
+ if self.spec is None:
141
+ return
142
+ outcome = _phase_outcome(report)
143
+ if outcome is None:
144
+ return
145
+ previous = self.results.get(report.nodeid)
146
+ message = ""
147
+ if outcome.is_failure:
148
+ message = _short_message(report)
149
+ if previous is None:
150
+ self.results[report.nodeid] = TestResult(
151
+ report.nodeid, outcome, report.duration, message
152
+ )
153
+ return
154
+ # Combine phases: any failure wins; durations add up.
155
+ merged = previous.outcome
156
+ if outcome.is_failure and not previous.outcome.is_failure:
157
+ merged = outcome
158
+ self.results[report.nodeid] = TestResult(
159
+ report.nodeid,
160
+ merged,
161
+ previous.duration + report.duration,
162
+ previous.message or message,
163
+ )
164
+
165
+ def pytest_sessionfinish(self, session: pytest.Session) -> None:
166
+ if self.spec is None or self.out is None:
167
+ return
168
+ order = {nodeid: i for i, nodeid in enumerate(self.spec.order)}
169
+ ordered = sorted(self.results.values(), key=lambda r: order.get(r.nodeid, len(order)))
170
+ _write_json(
171
+ self.out,
172
+ {
173
+ "results": [r.to_json() for r in ordered],
174
+ "state_changes": {
175
+ nodeid: [c.to_json() for c in changes]
176
+ for nodeid, changes in self.state_changes.items()
177
+ },
178
+ "sleep_sites": {
179
+ r.nodeid: [s.to_json() for s in timing.sites_for(r.nodeid)]
180
+ for r in ordered
181
+ if timing.sites_for(r.nodeid)
182
+ },
183
+ },
184
+ )
185
+
186
+
187
+ def seed_for_test(seed: int, nodeid: str) -> None:
188
+ """Seed the global RNGs for one test, independent of test order.
189
+
190
+ String seeds are hashed with SHA-512 by `random`, so this is stable across
191
+ processes and PYTHONHASHSEED values.
192
+ """
193
+ key = f"{seed}:{nodeid}"
194
+ random.seed(key)
195
+ numpy = sys.modules.get("numpy") # only if the project already uses it
196
+ if numpy is not None:
197
+ digest = hashlib.sha256(key.encode()).digest()
198
+ with contextlib.suppress(Exception):
199
+ numpy.random.seed(int.from_bytes(digest[:4], "big"))
200
+
201
+
202
+ def _phase_outcome(report: pytest.TestReport) -> Outcome | None:
203
+ if report.when == "call":
204
+ if report.passed:
205
+ return Outcome.PASSED
206
+ if report.skipped: # includes xfail
207
+ return Outcome.SKIPPED
208
+ return Outcome.FAILED
209
+ # setup / teardown
210
+ if report.failed:
211
+ return Outcome.ERROR
212
+ if report.skipped and report.when == "setup":
213
+ return Outcome.SKIPPED
214
+ return None
215
+
216
+
217
+ def _short_message(report: pytest.TestReport) -> str:
218
+ text = report.longreprtext or ""
219
+ lines = text.strip().splitlines()[-_MESSAGE_LINES:]
220
+ return truncate(redact("\n".join(lines)))
221
+
222
+
223
+ def _write_json(path: Path, data: Any) -> None:
224
+ tmp = path.with_suffix(path.suffix + ".tmp")
225
+ tmp.write_text(json.dumps(data), encoding="utf-8")
226
+ os.replace(tmp, path)
227
+
228
+
229
+ def pytest_configure(config: pytest.Config) -> None:
230
+ spec_path = config.getoption("flakehound_spec")
231
+ out_path = config.getoption("flakehound_out")
232
+ collect_path = config.getoption("flakehound_collect_out")
233
+ user_seed = config.getoption("flakehound_seed")
234
+ user_clock = config.getoption("flakehound_clock")
235
+ user_scale = config.getoption("flakehound_sleep_scale")
236
+ user_sites = config.getoption("flakehound_sleep_sites")
237
+ user_switch = config.getoption("flakehound_switch_interval")
238
+ user_timing = user_scale is not None or user_switch is not None
239
+ if not (spec_path or collect_path or user_seed is not None or user_clock or user_timing):
240
+ return
241
+ spec = None
242
+ if spec_path:
243
+ spec = RunSpec.from_json(json.loads(Path(spec_path).read_text(encoding="utf-8")))
244
+ target_clock = spec.clock if spec is not None else user_clock
245
+ if target_clock:
246
+ try:
247
+ clock.install(target_clock, str(config.rootpath))
248
+ except ValueError as exc:
249
+ raise pytest.UsageError(f"--flakehound-clock: {exc}") from exc
250
+ if spec is not None and spec.stresses_timing:
251
+ timing.install(
252
+ str(config.rootpath),
253
+ scale=spec.sleep_scale,
254
+ only_sites=spec.sleep_sites or None,
255
+ switch_interval=spec.switch_interval,
256
+ )
257
+ elif spec is None and user_timing:
258
+ if user_scale is not None and not 0 < user_scale <= 1:
259
+ raise pytest.UsageError("--flakehound-sleep-scale must be in (0, 1]")
260
+ if user_switch is not None and user_switch <= 0:
261
+ raise pytest.UsageError("--flakehound-switch-interval must be positive")
262
+ timing.install(
263
+ str(config.rootpath),
264
+ scale=user_scale,
265
+ only_sites=[x for x in (user_sites or "").split(",") if x] or None,
266
+ switch_interval=user_switch,
267
+ )
268
+ config.pluginmanager.register(
269
+ _Worker(
270
+ spec,
271
+ Path(out_path) if out_path else None,
272
+ Path(collect_path) if collect_path else None,
273
+ seed=user_seed,
274
+ ),
275
+ "flakehound-worker",
276
+ )