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
flakehound/__init__.py
ADDED
flakehound/__main__.py
ADDED
|
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
|
+
)
|