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.
@@ -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