pytest-filemon 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.
- pytest_filemon/__init__.py +12 -0
- pytest_filemon/fingerprint.py +334 -0
- pytest_filemon/imports.py +402 -0
- pytest_filemon/plugin.py +577 -0
- pytest_filemon/selection.py +134 -0
- pytest_filemon/store.py +147 -0
- pytest_filemon/util.py +87 -0
- pytest_filemon-0.1.0.dist-info/METADATA +206 -0
- pytest_filemon-0.1.0.dist-info/RECORD +12 -0
- pytest_filemon-0.1.0.dist-info/WHEEL +4 -0
- pytest_filemon-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_filemon-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""pytest-filemon: file-level test impact analysis (TIA) for pytest.
|
|
2
|
+
|
|
3
|
+
Records which repository files each test imports — every import statement,
|
|
4
|
+
cached or not, through ``builtins.__import__`` and
|
|
5
|
+
``importlib.import_module`` wraps — stores the data as JSON under
|
|
6
|
+
``.pytest_filemon/``, and uses file fingerprints to select only the tests
|
|
7
|
+
whose dependency files changed. No ``sys.monitoring``, no bytecode
|
|
8
|
+
analysis: works on any Python that runs pytest. The block-level sibling
|
|
9
|
+
is ``pytest-mortadelo``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Change detection without asking git "what differs from a base commit?".
|
|
2
|
+
|
|
3
|
+
``git diff <base>`` plus ``git ls-files --others`` answer a question we do not
|
|
4
|
+
actually have. They report every file that differs from a commit, and the
|
|
5
|
+
second one scans the whole checkout (790 ms on a 10k-file repository with
|
|
6
|
+
46k untracked files). What selection needs is narrower: *do the files this
|
|
7
|
+
data depends on still look like they did when it was recorded?*
|
|
8
|
+
|
|
9
|
+
At record time we store a **snapshot**:
|
|
10
|
+
|
|
11
|
+
``files``
|
|
12
|
+
``{rel: [size, mtime_ns, content_hash]}`` for every dependency file and
|
|
13
|
+
every ``.py`` file in a *watched* directory.
|
|
14
|
+
``dirs``
|
|
15
|
+
``{reldir: [subdirectory names]}`` for the watched directories: every
|
|
16
|
+
directory that holds a dependency file, and all of its ancestors up to the
|
|
17
|
+
repository root.
|
|
18
|
+
``names``
|
|
19
|
+
Only in widened mode (see below): ``{reldir: [file names]}``, so that *any*
|
|
20
|
+
new non-hidden file in a watched directory -- not just a ``.py`` -- counts
|
|
21
|
+
as new.
|
|
22
|
+
``at``
|
|
23
|
+
When the snapshot was taken (``time.time_ns()``), for the racy-clean rule.
|
|
24
|
+
|
|
25
|
+
At run time :func:`detect_changes` needs no git at all in the common case:
|
|
26
|
+
|
|
27
|
+
* ``stat`` every recorded file. Same size, same mtime, and an mtime older than
|
|
28
|
+
the snapshot by more than :data:`RACY_NS` -> unchanged. Anything else is
|
|
29
|
+
settled by hashing the bytes (a ``touch`` or a ``git checkout`` that rewrites
|
|
30
|
+
identical content is *not* a change; the same applies to data recorded on
|
|
31
|
+
another machine, where every mtime differs).
|
|
32
|
+
* list the watched directories. A ``.py`` file that is not in ``files``, or a
|
|
33
|
+
subdirectory that is not in ``dirs``, is *new*.
|
|
34
|
+
|
|
35
|
+
Only when something new appears is git asked, once, and only about those
|
|
36
|
+
paths (``ls-files --cached --others --exclude-standard -- <paths>``): ignore
|
|
37
|
+
rules stay git's job, but a repository with a huge untracked tree no longer
|
|
38
|
+
pays for listing it on every run. Hidden directories, ``__pycache__``,
|
|
39
|
+
``*.egg-info`` and the usual environment directories are never reported as new.
|
|
40
|
+
|
|
41
|
+
When a recorded test spawns a process, the snapshot is **widened to every
|
|
42
|
+
tracked file** (``"all": true``): a child may read any file the parent never
|
|
43
|
+
touched, so "nothing changed" is only provable if everything is watched.
|
|
44
|
+
That is what lets a rerun with nothing changed skip such tests via the
|
|
45
|
+
fast path. Files above
|
|
46
|
+
:data:`EXTRA_HASH_LIMIT` are fingerprinted by stat alone (a differing mtime
|
|
47
|
+
counts as a change: never read, never hashed), and a repository with more than
|
|
48
|
+
:data:`TRACKED_LIMIT` tracked files is not widened at all.
|
|
49
|
+
|
|
50
|
+
A caller that analyzes file content (mortadelo's block analysis) can pass
|
|
51
|
+
the digests it computed; a caller that records whole files (filemon) has
|
|
52
|
+
no analyzed bytes and passes none. If the file changed between being parsed and being snapshotted, its
|
|
53
|
+
stored mtime is ``-1``: the next run never trusts the stat shortcut for it and
|
|
54
|
+
the hash mismatch reports the change.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import hashlib
|
|
60
|
+
import os
|
|
61
|
+
import time
|
|
62
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
63
|
+
from pathlib import Path, PurePosixPath
|
|
64
|
+
|
|
65
|
+
from .util import _EXCLUDED_SEGMENTS
|
|
66
|
+
|
|
67
|
+
SNAPSHOT_VERSION = 1
|
|
68
|
+
|
|
69
|
+
# Widening to every tracked file (data with spawning tests) is bounded: a
|
|
70
|
+
# big repository keeps the old "spawners always re-run" behavior rather than
|
|
71
|
+
# stat'ing a six-figure file list on every run.
|
|
72
|
+
TRACKED_LIMIT = 30_000
|
|
73
|
+
# Beyond this size a widened (non-dependency) file is fingerprinted by stat only.
|
|
74
|
+
EXTRA_HASH_LIMIT = 1_000_000
|
|
75
|
+
|
|
76
|
+
# A file modified within this window before the snapshot may have been written
|
|
77
|
+
# again within the same timestamp tick (coarse filesystems: 1-2 s): never
|
|
78
|
+
# trust "same size and mtime" for it. Git's "racily clean" rule.
|
|
79
|
+
RACY_NS = 2_000_000_000
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _content_hash(data: bytes) -> str:
|
|
83
|
+
"""Same digest as mortadelo's block analysis (blake2b, 16 bytes)."""
|
|
84
|
+
return hashlib.blake2b(data, digest_size=16).hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _skip_dir(name: str) -> bool:
|
|
88
|
+
"""Directories that are never reported as new project code."""
|
|
89
|
+
return (
|
|
90
|
+
name.startswith(".")
|
|
91
|
+
or name == "__pycache__"
|
|
92
|
+
or name in _EXCLUDED_SEGMENTS
|
|
93
|
+
or name.endswith(".egg-info")
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _join(directory: str, name: str) -> str:
|
|
98
|
+
return f"{directory}/{name}" if directory else name
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _fingerprint(
|
|
102
|
+
path: Path,
|
|
103
|
+
digest: str | None,
|
|
104
|
+
previous: list | None = None,
|
|
105
|
+
trusted_before: int = 0,
|
|
106
|
+
max_bytes: int | None = None,
|
|
107
|
+
) -> list | None:
|
|
108
|
+
"""``[size, mtime_ns, hash]`` for *path*, or None if it cannot be read.
|
|
109
|
+
|
|
110
|
+
*digest* is the hash of the content the block analysis actually read:
|
|
111
|
+
when the bytes on disk no longer match it the file changed during
|
|
112
|
+
the run, and the entry is poisoned (mtime ``-1``) so the next run re-hashes.
|
|
113
|
+
|
|
114
|
+
*previous* is the same file's entry in the snapshot being replaced. If
|
|
115
|
+
its size and mtime are unchanged and the mtime is comfortably older than
|
|
116
|
+
that snapshot (*trusted_before*), the bytes are the ones it hashed: reuse
|
|
117
|
+
the hash instead of reading the file again -- the very shortcut
|
|
118
|
+
:func:`detect_changes` takes -- so rewriting the data costs stats, not reads
|
|
119
|
+
(507 ms -> a few ms for 3,250 files).
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
st = path.stat()
|
|
123
|
+
if max_bytes is not None and st.st_size > max_bytes:
|
|
124
|
+
return [st.st_size, st.st_mtime_ns, ""] # stat-only: never read
|
|
125
|
+
if (
|
|
126
|
+
previous is not None
|
|
127
|
+
and previous[1] >= 0
|
|
128
|
+
and previous[1] == st.st_mtime_ns
|
|
129
|
+
and previous[1] < trusted_before
|
|
130
|
+
and previous[0] == st.st_size
|
|
131
|
+
and (digest is None or digest == previous[2])
|
|
132
|
+
):
|
|
133
|
+
return [st.st_size, st.st_mtime_ns, previous[2]]
|
|
134
|
+
current = _content_hash(path.read_bytes())
|
|
135
|
+
except OSError:
|
|
136
|
+
return None
|
|
137
|
+
if digest is not None and digest != current:
|
|
138
|
+
return [st.st_size, -1, digest]
|
|
139
|
+
return [st.st_size, st.st_mtime_ns, current]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_snapshot(
|
|
143
|
+
root: Path,
|
|
144
|
+
dep_files: Iterable[str],
|
|
145
|
+
digests: Mapping[str, str] | None = None,
|
|
146
|
+
previous: Mapping | None = None,
|
|
147
|
+
extra_files: Iterable[str] | None = None,
|
|
148
|
+
) -> dict:
|
|
149
|
+
"""The fingerprint snapshot to store with the freshly recorded data.
|
|
150
|
+
|
|
151
|
+
*previous* is the snapshot being replaced (if any): its hashes are reused
|
|
152
|
+
for files that provably did not change since (see :func:`_fingerprint`).
|
|
153
|
+
|
|
154
|
+
*extra_files* (every tracked file, when some recorded test spawns a
|
|
155
|
+
process) are fingerprinted too and mark the snapshot ``"all": true``.
|
|
156
|
+
"""
|
|
157
|
+
at = time.time_ns()
|
|
158
|
+
digests = digests or {}
|
|
159
|
+
old_files: Mapping = {}
|
|
160
|
+
trusted_before = 0
|
|
161
|
+
if (
|
|
162
|
+
previous
|
|
163
|
+
and previous.get("v") == SNAPSHOT_VERSION
|
|
164
|
+
and isinstance(previous.get("files"), dict)
|
|
165
|
+
and isinstance(previous.get("at"), int)
|
|
166
|
+
):
|
|
167
|
+
old_files = previous["files"]
|
|
168
|
+
trusted_before = previous["at"] - RACY_NS
|
|
169
|
+
deps = {f for f in dep_files if isinstance(f, str) and f}
|
|
170
|
+
|
|
171
|
+
watched: set[str] = {""} # the root is always watched (setup.py, conftest.py)
|
|
172
|
+
for rel in deps:
|
|
173
|
+
for parent in PurePosixPath(rel).parents:
|
|
174
|
+
watched.add("" if str(parent) == "." else str(parent))
|
|
175
|
+
|
|
176
|
+
files: dict[str, list] = {}
|
|
177
|
+
dirs: dict[str, list[str]] = {}
|
|
178
|
+
names: dict[str, list[str]] = {}
|
|
179
|
+
widened = extra_files is not None
|
|
180
|
+
for directory in sorted(watched):
|
|
181
|
+
base = root / directory if directory else root
|
|
182
|
+
try:
|
|
183
|
+
with os.scandir(base) as entries:
|
|
184
|
+
subdirs: list[str] = []
|
|
185
|
+
listed: list[str] = []
|
|
186
|
+
for entry in entries:
|
|
187
|
+
name = entry.name
|
|
188
|
+
try:
|
|
189
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
190
|
+
except OSError:
|
|
191
|
+
continue
|
|
192
|
+
if is_dir:
|
|
193
|
+
if not _skip_dir(name):
|
|
194
|
+
subdirs.append(name)
|
|
195
|
+
continue
|
|
196
|
+
if widened and not name.startswith("."):
|
|
197
|
+
listed.append(name)
|
|
198
|
+
if name.endswith(".py"):
|
|
199
|
+
rel = _join(directory, name)
|
|
200
|
+
record = _fingerprint(
|
|
201
|
+
root / rel, digests.get(rel), old_files.get(rel), trusted_before
|
|
202
|
+
)
|
|
203
|
+
if record is not None:
|
|
204
|
+
files[rel] = record
|
|
205
|
+
except OSError:
|
|
206
|
+
continue
|
|
207
|
+
dirs[directory] = sorted(subdirs)
|
|
208
|
+
if widened:
|
|
209
|
+
names[directory] = sorted(listed)
|
|
210
|
+
|
|
211
|
+
for rel in deps:
|
|
212
|
+
if rel not in files:
|
|
213
|
+
record = _fingerprint(
|
|
214
|
+
root / rel, digests.get(rel), old_files.get(rel), trusted_before
|
|
215
|
+
)
|
|
216
|
+
if record is not None:
|
|
217
|
+
files[rel] = record
|
|
218
|
+
if extra_files is not None:
|
|
219
|
+
for rel in extra_files:
|
|
220
|
+
if rel in files or not isinstance(rel, str) or not rel:
|
|
221
|
+
continue
|
|
222
|
+
record = _fingerprint(
|
|
223
|
+
root / rel, None, old_files.get(rel), trusted_before, EXTRA_HASH_LIMIT
|
|
224
|
+
)
|
|
225
|
+
if record is not None:
|
|
226
|
+
files[rel] = record
|
|
227
|
+
snapshot = {
|
|
228
|
+
"v": SNAPSHOT_VERSION,
|
|
229
|
+
"at": at,
|
|
230
|
+
"files": files,
|
|
231
|
+
"dirs": dirs,
|
|
232
|
+
"all": widened,
|
|
233
|
+
}
|
|
234
|
+
if widened:
|
|
235
|
+
snapshot["names"] = names
|
|
236
|
+
return snapshot
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def detect_changes(
|
|
240
|
+
root: Path,
|
|
241
|
+
snapshot: Mapping,
|
|
242
|
+
confirm: Callable[[list[str]], set[str] | None] | None = None,
|
|
243
|
+
) -> set[str] | None:
|
|
244
|
+
"""Repo-relative files that differ from *snapshot*, or ``None`` when that
|
|
245
|
+
cannot be decided (unknown snapshot version, an unreadable directory, git
|
|
246
|
+
unable to confirm new paths): the caller then falls back to ``git diff``.
|
|
247
|
+
|
|
248
|
+
*confirm* receives the new ``.py`` files and new directories (directories
|
|
249
|
+
with a trailing ``/``) and returns the project files git knows under them
|
|
250
|
+
(tracked, or untracked and not ignored). It is called at most once, and
|
|
251
|
+
only when something new showed up.
|
|
252
|
+
"""
|
|
253
|
+
if snapshot.get("v") != SNAPSHOT_VERSION:
|
|
254
|
+
return None
|
|
255
|
+
files = snapshot.get("files")
|
|
256
|
+
dirs = snapshot.get("dirs")
|
|
257
|
+
at = snapshot.get("at")
|
|
258
|
+
if not isinstance(files, dict) or not isinstance(dirs, dict) or not isinstance(at, int):
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
changed: set[str] = set()
|
|
262
|
+
trusted_before = at - RACY_NS
|
|
263
|
+
for rel, record in files.items():
|
|
264
|
+
try:
|
|
265
|
+
size, mtime, digest = record
|
|
266
|
+
st = os.stat(root / rel)
|
|
267
|
+
except FileNotFoundError:
|
|
268
|
+
changed.add(rel)
|
|
269
|
+
continue
|
|
270
|
+
except NotADirectoryError:
|
|
271
|
+
changed.add(rel)
|
|
272
|
+
continue
|
|
273
|
+
except (OSError, ValueError, TypeError):
|
|
274
|
+
return None
|
|
275
|
+
if st.st_size != size:
|
|
276
|
+
changed.add(rel)
|
|
277
|
+
continue
|
|
278
|
+
if mtime >= 0 and st.st_mtime_ns == mtime and mtime < trusted_before:
|
|
279
|
+
continue # same size, same mtime, comfortably older than the snapshot
|
|
280
|
+
if not digest:
|
|
281
|
+
changed.add(rel) # stat-only entry (large file): cannot be verified
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
now = _content_hash((root / rel).read_bytes())
|
|
285
|
+
except OSError:
|
|
286
|
+
changed.add(rel)
|
|
287
|
+
continue
|
|
288
|
+
if now != digest:
|
|
289
|
+
changed.add(rel)
|
|
290
|
+
|
|
291
|
+
names = snapshot.get("names") if snapshot.get("all") else None
|
|
292
|
+
suspects: list[str] = []
|
|
293
|
+
for directory, known in dirs.items():
|
|
294
|
+
known_dirs = set(known) if isinstance(known, list) else set()
|
|
295
|
+
known_names = (
|
|
296
|
+
set(names.get(directory, ())) if isinstance(names, dict) else None
|
|
297
|
+
)
|
|
298
|
+
try:
|
|
299
|
+
with os.scandir(root / directory if directory else root) as entries:
|
|
300
|
+
for entry in entries:
|
|
301
|
+
name = entry.name
|
|
302
|
+
try:
|
|
303
|
+
is_dir = entry.is_dir(follow_symlinks=False)
|
|
304
|
+
except OSError:
|
|
305
|
+
continue
|
|
306
|
+
if is_dir:
|
|
307
|
+
child = _join(directory, name)
|
|
308
|
+
if name in known_dirs or child in dirs or _skip_dir(name):
|
|
309
|
+
continue
|
|
310
|
+
suspects.append(child + "/")
|
|
311
|
+
elif known_names is not None:
|
|
312
|
+
# widened: any new non-hidden file counts, not just .py
|
|
313
|
+
if name not in known_names and not name.startswith("."):
|
|
314
|
+
suspects.append(_join(directory, name))
|
|
315
|
+
elif name.endswith(".py") and _join(directory, name) not in files:
|
|
316
|
+
suspects.append(_join(directory, name))
|
|
317
|
+
except FileNotFoundError:
|
|
318
|
+
continue # the directory is gone: its recorded files were reported above
|
|
319
|
+
except OSError:
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
if suspects:
|
|
323
|
+
if confirm is None:
|
|
324
|
+
return None
|
|
325
|
+
project = confirm(suspects)
|
|
326
|
+
if project is None:
|
|
327
|
+
return None
|
|
328
|
+
for rel in project:
|
|
329
|
+
parts = PurePosixPath(rel).parts
|
|
330
|
+
if (names is not None or rel.endswith(".py")) and not any(
|
|
331
|
+
_skip_dir(p) for p in parts[:-1]
|
|
332
|
+
):
|
|
333
|
+
changed.add(rel)
|
|
334
|
+
return changed
|