diffcone 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.
- diffcone/__init__.py +13 -0
- diffcone/cache.py +528 -0
- diffcone/check.py +399 -0
- diffcone/classify.py +255 -0
- diffcone/cli.py +886 -0
- diffcone/collect.py +846 -0
- diffcone/cython.py +757 -0
- diffcone/declarations.py +111 -0
- diffcone/discovery/__init__.py +121 -0
- diffcone/discovery/asv_static.py +494 -0
- diffcone/discovery/common.py +130 -0
- diffcone/discovery/pytest_static.py +2680 -0
- diffcone/evidence.py +738 -0
- diffcone/evidence_plan.py +1487 -0
- diffcone/execution.py +1681 -0
- diffcone/indexer/__init__.py +48 -0
- diffcone/indexer/core.py +286 -0
- diffcone/indexer/definitions.py +292 -0
- diffcone/indexer/dynamics.py +406 -0
- diffcone/indexer/facts.py +373 -0
- diffcone/indexer/literals.py +388 -0
- diffcone/indexer/references.py +895 -0
- diffcone/indexer/resolver.py +1001 -0
- diffcone/indexer/scopes.py +276 -0
- diffcone/indexer/state.py +83 -0
- diffcone/indexer/symbols.py +441 -0
- diffcone/indexer/syntax.py +173 -0
- diffcone/manifest.py +166 -0
- diffcone/model.py +191 -0
- diffcone/planner.py +1453 -0
- diffcone/report.py +256 -0
- diffcone/selection.py +133 -0
- diffcone/snapshot.py +739 -0
- diffcone/testing.py +200 -0
- diffcone-0.1.0.dist-info/METADATA +133 -0
- diffcone-0.1.0.dist-info/RECORD +39 -0
- diffcone-0.1.0.dist-info/WHEEL +4 -0
- diffcone-0.1.0.dist-info/entry_points.txt +3 -0
- diffcone-0.1.0.dist-info/licenses/LICENSE +21 -0
diffcone/collect.py
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
"""pytest plugin that records execution evidence.
|
|
2
|
+
|
|
3
|
+
Loaded as ``-p diffcone_collect``: ``diffcone`` links this file into a
|
|
4
|
+
directory of its own on ``PYTHONPATH``, so it imports nothing of diffcone and
|
|
5
|
+
needs nothing but the standard library and pytest.
|
|
6
|
+
|
|
7
|
+
It runs inside the project's own test process, started by ``diffcone
|
|
8
|
+
collect`` or ``diffcone run --evidence`` (execution.py), never by planning.
|
|
9
|
+
It must not change what a test does: it prints nothing, and an exception
|
|
10
|
+
inside it is recorded as a collection error, which makes diffcone refuse
|
|
11
|
+
the store, rather than raised into the suite.
|
|
12
|
+
|
|
13
|
+
What it records, per test (parameter cases folded into their function, as
|
|
14
|
+
``validate`` folds them):
|
|
15
|
+
|
|
16
|
+
* the code objects executed in the test's setup, call and teardown, and in
|
|
17
|
+
the setup of every non-function-scoped fixture the test activated
|
|
18
|
+
(credited to every such test, not only the first; and the keys of those
|
|
19
|
+
fixtures, so the fold credits what another process's setup of the same
|
|
20
|
+
fixture ran too: an xdist "compute once" fixture runs in one worker).
|
|
21
|
+
``sys.monitoring`` ``PY_START`` and ``PY_RESUME`` (a generator or
|
|
22
|
+
coroutine resumed in a later test) with DISABLE, re-armed at every window
|
|
23
|
+
boundary, and ``PY_THROW`` (re-entered by ``.throw()``/``.close()``);
|
|
24
|
+
* the repository paths it opened, ``stat``ed, ``access``ed or listed (audit
|
|
25
|
+
hooks, ``sqlite3.connect`` and ``ctypes.dlopen`` included, and wrappers
|
|
26
|
+
of ``os.stat``/``os.lstat``/``os.access``, which never warn, so the extra
|
|
27
|
+
frame cannot move a warning's ``stacklevel``), with ``/`` separators;
|
|
28
|
+
* whether it started a subprocess or a subinterpreter, or left a thread
|
|
29
|
+
running (code no record names runs there).
|
|
30
|
+
|
|
31
|
+
Outside every test window it records the code and paths of imports,
|
|
32
|
+
collection and hooks; for each code object run by an import, the innermost
|
|
33
|
+
module whose top-level code was running (``import_by``); and the code that
|
|
34
|
+
ran outside every window while no import was running (hooks, collection).
|
|
35
|
+
``functools`` caches of project code are cleared before each test so a
|
|
36
|
+
value one test computed is recomputed, and recorded, by the next.
|
|
37
|
+
|
|
38
|
+
Environment variables:
|
|
39
|
+
|
|
40
|
+
``DIFFCONE_COLLECT_OUT`` directory for the raw records; recording is
|
|
41
|
+
off without it
|
|
42
|
+
``DIFFCONE_COLLECT_ROOT`` the checkout root (default: the current directory)
|
|
43
|
+
``DIFFCONE_COLLECT_REPO`` the repository it was made from (``collect --rev``
|
|
44
|
+
runs a temporary worktree); the project's own
|
|
45
|
+
editable install there is not environment
|
|
46
|
+
``DIFFCONE_COLLECT_PACKAGES`` comma-separated top-level packages of the
|
|
47
|
+
project: a module of one of them loaded from
|
|
48
|
+
outside the root means an installed copy ran
|
|
49
|
+
``DIFFCONE_COLLECT_REVERSE`` run the collected tests in reverse order
|
|
50
|
+
``DIFFCONE_ENV_VARIABLES`` comma-separated project variables that change
|
|
51
|
+
what tests do (pandas' ``PANDAS_FUTURE``),
|
|
52
|
+
recorded with the environment and checked
|
|
53
|
+
``DIFFCONE_CHECK_ENV`` check mode: the environment hash evidence was
|
|
54
|
+
recorded under; the plugin always writes
|
|
55
|
+
``DIFFCONE_CHECK_REPORT`` (``{"match": bool,
|
|
56
|
+
"environment": {...}}``) and, on a mismatch,
|
|
57
|
+
stops the session before any test runs
|
|
58
|
+
``DIFFCONE_CHECK_ONLY`` with check mode: stop after the check (the
|
|
59
|
+
evidence plan selected nothing to run)
|
|
60
|
+
|
|
61
|
+
Files written per process: ``process-<pid>.json`` (code table, import-time
|
|
62
|
+
records, environment, errors) and ``tests-<pid>.bin`` (length-prefixed
|
|
63
|
+
zlib-compressed JSON records, one per run of consecutive items of a test).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
from __future__ import annotations
|
|
67
|
+
|
|
68
|
+
import functools
|
|
69
|
+
import gc
|
|
70
|
+
import hashlib
|
|
71
|
+
import importlib
|
|
72
|
+
import json
|
|
73
|
+
import os
|
|
74
|
+
import platform
|
|
75
|
+
import re
|
|
76
|
+
import struct
|
|
77
|
+
import sys
|
|
78
|
+
import threading
|
|
79
|
+
import zlib
|
|
80
|
+
from typing import Any
|
|
81
|
+
from urllib.parse import urlparse
|
|
82
|
+
|
|
83
|
+
import pytest
|
|
84
|
+
|
|
85
|
+
TOOL = 3
|
|
86
|
+
FLAG_SUBPROCESS = 1
|
|
87
|
+
ENV_VARIABLES = ("PYTHONHASHSEED", "TZ", "LANG", "LC_ALL", "PYTHONWARNINGS")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _variables() -> tuple[str, ...]:
|
|
91
|
+
"""The variables the environment includes: these, and the project's own
|
|
92
|
+
(``DIFFCONE_ENV_VARIABLES``, from ``collect --env-var``)."""
|
|
93
|
+
extra = {v for v in os.environ.get("DIFFCONE_ENV_VARIABLES", "").split(",") if v}
|
|
94
|
+
return ENV_VARIABLES + tuple(sorted(extra - set(ENV_VARIABLES)))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
SUBPROCESS_EVENTS = frozenset(
|
|
98
|
+
{
|
|
99
|
+
"subprocess.Popen",
|
|
100
|
+
"os.system",
|
|
101
|
+
"os.exec",
|
|
102
|
+
"os.posix_spawn",
|
|
103
|
+
"os.spawn",
|
|
104
|
+
"os.fork",
|
|
105
|
+
"os.forkpty",
|
|
106
|
+
"os.startfile",
|
|
107
|
+
# Windows' process creation (multiprocessing's spawn there).
|
|
108
|
+
"_winapi.CreateProcess",
|
|
109
|
+
}
|
|
110
|
+
)
|
|
111
|
+
# Files opened by C code, which raises no ``open`` event: the first
|
|
112
|
+
# argument is the path.
|
|
113
|
+
C_OPEN_EVENTS = frozenset({"sqlite3.connect", "ctypes.dlopen"})
|
|
114
|
+
LISTING_EVENTS = frozenset({"os.listdir", "os.scandir"})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- environment
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _editable(dist) -> str | None:
|
|
121
|
+
"""The local directory of an editable install, else None. The checkout's
|
|
122
|
+
own (a source checkout, whose version string ``3.0.0.dev0+1234.gabcdef``
|
|
123
|
+
changes with every commit) is code the index reads, not environment."""
|
|
124
|
+
try:
|
|
125
|
+
text = dist.read_text("direct_url.json")
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
if not text:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
data = json.loads(text)
|
|
132
|
+
except ValueError:
|
|
133
|
+
return None
|
|
134
|
+
url = urlparse(data.get("url", ""))
|
|
135
|
+
if not (data.get("dir_info", {}).get("editable") and url.scheme == "file"):
|
|
136
|
+
return None
|
|
137
|
+
return os.path.realpath(url.path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _in_checkout(path: str) -> bool:
|
|
141
|
+
"""Inside the checkout being run, or the repository it was made from
|
|
142
|
+
(``collect --rev`` runs a temporary worktree, while the project's own
|
|
143
|
+
editable install points at the repository)."""
|
|
144
|
+
roots = {
|
|
145
|
+
os.path.realpath(r)
|
|
146
|
+
for r in (
|
|
147
|
+
os.environ.get("DIFFCONE_COLLECT_ROOT") or os.getcwd(),
|
|
148
|
+
os.environ.get("DIFFCONE_COLLECT_REPO") or "",
|
|
149
|
+
)
|
|
150
|
+
if r
|
|
151
|
+
}
|
|
152
|
+
return any(path == root or path.startswith(root + os.sep) for root in roots)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def environment() -> dict:
|
|
156
|
+
"""What a recorded path depends on besides the code: the interpreter, the
|
|
157
|
+
installed distributions and a few variables. Computed identically when
|
|
158
|
+
recording and when checking."""
|
|
159
|
+
import importlib.metadata as metadata
|
|
160
|
+
|
|
161
|
+
dists = set()
|
|
162
|
+
for dist in metadata.distributions():
|
|
163
|
+
try:
|
|
164
|
+
name = dist.metadata["Name"] or ""
|
|
165
|
+
except Exception:
|
|
166
|
+
name = ""
|
|
167
|
+
if not name:
|
|
168
|
+
continue
|
|
169
|
+
local = _editable(dist)
|
|
170
|
+
if local is not None and _in_checkout(local):
|
|
171
|
+
continue
|
|
172
|
+
entry = f"{name.lower().replace('_', '-')}=={dist.version}"
|
|
173
|
+
# A sibling editable install (``pip install -e ../lib``) is not traced
|
|
174
|
+
# either, but where it lives is part of what runs.
|
|
175
|
+
dists.add(entry + (f" @ {local}" if local is not None else ""))
|
|
176
|
+
# The user's own entries: diffcone's plugin directory and the checkout's
|
|
177
|
+
# source roots (``collect`` puts them first; a recorded commit's checkout
|
|
178
|
+
# is a temporary worktree) are diffcone's, not the environment's.
|
|
179
|
+
pythonpath = [
|
|
180
|
+
p
|
|
181
|
+
for p in os.environ.get("PYTHONPATH", "").split(os.pathsep)
|
|
182
|
+
if p
|
|
183
|
+
and not os.path.basename(p.rstrip(os.sep)).startswith("diffcone-plugin-")
|
|
184
|
+
and not _in_checkout(os.path.realpath(p))
|
|
185
|
+
]
|
|
186
|
+
return {
|
|
187
|
+
"implementation": sys.implementation.name,
|
|
188
|
+
"python": sys.version,
|
|
189
|
+
"platform": sys.platform,
|
|
190
|
+
"machine": platform.machine(),
|
|
191
|
+
# ``-O`` strips asserts, and the calls inside them.
|
|
192
|
+
"optimize": sys.flags.optimize,
|
|
193
|
+
"distributions": sorted(dists),
|
|
194
|
+
"variables": {k: os.environ.get(k) for k in _variables()},
|
|
195
|
+
"pythonpath": pythonpath,
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def environment_hash(env: dict) -> str:
|
|
200
|
+
return hashlib.sha256(json.dumps(env, sort_keys=True).encode()).hexdigest()[:16]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------- state
|
|
204
|
+
|
|
205
|
+
OUT = os.environ.get("DIFFCONE_COLLECT_OUT")
|
|
206
|
+
_root = os.environ.get("DIFFCONE_COLLECT_ROOT") or os.getcwd()
|
|
207
|
+
# Both spellings: a code object's file name is the path it was imported by
|
|
208
|
+
# (``/tmp/x`` on macOS is ``/private/tmp/x``).
|
|
209
|
+
ROOTS = tuple(sorted({os.path.abspath(_root) + os.sep, os.path.realpath(_root) + os.sep}))
|
|
210
|
+
PACKAGES = frozenset(p for p in os.environ.get("DIFFCONE_COLLECT_PACKAGES", "").split(",") if p)
|
|
211
|
+
IGNORED_DIRS = (".git" + os.sep, ".diffcone" + os.sep)
|
|
212
|
+
INSTALLED = (os.sep + "site-packages" + os.sep, os.sep + "dist-packages" + os.sep)
|
|
213
|
+
|
|
214
|
+
errors: list[str] = []
|
|
215
|
+
codes: dict[object, int] = {}
|
|
216
|
+
table: list[list] = []
|
|
217
|
+
active: list[dict] = [] # open windows, innermost last
|
|
218
|
+
importing: list[str] = [] # modules whose top-level code is running, innermost last
|
|
219
|
+
import_by: dict[int, set[str]] = {}
|
|
220
|
+
finished = False # the process record is written
|
|
221
|
+
hook_codes: set[int] = set() # ran outside every test window with no import running
|
|
222
|
+
# Opened or stat'ed, and listed, by project code outside every test window.
|
|
223
|
+
import_paths: dict[str, set[str]] = {}
|
|
224
|
+
import_dirs: dict[str, set[str]] = {}
|
|
225
|
+
fixture_windows: dict[tuple[str, str, str], dict] = {}
|
|
226
|
+
collected: set[str] = set() # every test collected, parameters folded
|
|
227
|
+
collection_ran = False # this process collected (an xdist controller does not)
|
|
228
|
+
used_fixtures: dict[str, list] = {}
|
|
229
|
+
caches: list = []
|
|
230
|
+
recording = False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _window() -> dict:
|
|
234
|
+
# ``fixtures``: the non-function-scoped fixtures a test used, by key, so
|
|
235
|
+
# a fixture another process (an xdist worker) set up is credited too.
|
|
236
|
+
return {"codes": set(), "paths": set(), "dirs": set(), "flags": 0, "fixtures": set()}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
import_window = _window()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _windows():
|
|
243
|
+
return active if active else (import_window,)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _error(where: str, exc: BaseException) -> None:
|
|
247
|
+
if len(errors) < 50:
|
|
248
|
+
errors.append(f"{where}: {type(exc).__name__}: {exc}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _relative(path: str) -> str | None:
|
|
252
|
+
"""The checkout-relative path, "" for the root itself; None outside the
|
|
253
|
+
checkout, and for an environment kept inside it (``.venv``). Normalised
|
|
254
|
+
first: a module imported through ``sys.path.insert(0, ".../tests/../src")``
|
|
255
|
+
(a common conftest idiom) has ``..`` in its code objects' file names."""
|
|
256
|
+
if os.path.isabs(path):
|
|
257
|
+
path = os.path.normpath(path)
|
|
258
|
+
for root in ROOTS:
|
|
259
|
+
if path + os.sep == root:
|
|
260
|
+
return ""
|
|
261
|
+
if path.startswith(root):
|
|
262
|
+
rel = path[len(root) :]
|
|
263
|
+
if rel.startswith(IGNORED_DIRS) or any(p in rel for p in INSTALLED):
|
|
264
|
+
return None
|
|
265
|
+
# Records use ``/`` on every platform, as the index does.
|
|
266
|
+
return rel.replace(os.sep, "/") if os.sep != "/" else rel
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
CYTHON_SUFFIXES = (".pyx", ".pxd", ".pxi")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_code_paths: dict[str, str | None] = {}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _code_path(name: str) -> str | None:
|
|
277
|
+
"""A code object's checkout-relative file. A profiled Cython build names
|
|
278
|
+
its source relative to the directory it was built from, the checkout
|
|
279
|
+
root, rather than by an absolute path. Cached: a profiled build raises a
|
|
280
|
+
start event on every call of a Cython function, so this runs per call."""
|
|
281
|
+
try:
|
|
282
|
+
return _code_paths[name]
|
|
283
|
+
except KeyError:
|
|
284
|
+
pass
|
|
285
|
+
path: str | None = name
|
|
286
|
+
if not os.path.isabs(name) and name.endswith(CYTHON_SUFFIXES):
|
|
287
|
+
path = os.path.normpath(os.path.join(ROOTS[0], name))
|
|
288
|
+
if not os.path.isfile(path):
|
|
289
|
+
path = None
|
|
290
|
+
rel = _code_paths[name] = _relative(path) if path is not None else None
|
|
291
|
+
return rel
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --------------------------------------------------------------------------- monitoring
|
|
295
|
+
|
|
296
|
+
# None before 3.12, where pytest_configure stops before anything uses it.
|
|
297
|
+
mon: Any = getattr(sys, "monitoring", None)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _on_start(code, offset):
|
|
301
|
+
try:
|
|
302
|
+
rel = _code_path(code.co_filename)
|
|
303
|
+
if rel is None:
|
|
304
|
+
return mon.DISABLE
|
|
305
|
+
i = codes.get(code)
|
|
306
|
+
if i is None:
|
|
307
|
+
i = codes[code] = len(table)
|
|
308
|
+
table.append([rel, code.co_firstlineno, code.co_qualname])
|
|
309
|
+
for w in _windows():
|
|
310
|
+
w["codes"].add(i)
|
|
311
|
+
if not active and not importing and code.co_name != "<module>":
|
|
312
|
+
hook_codes.add(i)
|
|
313
|
+
if code.co_name == "<module>":
|
|
314
|
+
# Credit what this import runs to this module: watch for its end,
|
|
315
|
+
# and re-arm so code that already ran elsewhere is seen again.
|
|
316
|
+
importing.append(rel)
|
|
317
|
+
mon.set_local_events(TOOL, code, mon.events.PY_RETURN)
|
|
318
|
+
mon.restart_events()
|
|
319
|
+
elif importing:
|
|
320
|
+
import_by.setdefault(i, set()).add(importing[-1])
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
_error("PY_START", exc)
|
|
323
|
+
return mon.DISABLE
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _on_resume(code, offset):
|
|
327
|
+
# A generator or coroutine started earlier (in another test, at import)
|
|
328
|
+
# and resumed now: the running test executes its code too.
|
|
329
|
+
try:
|
|
330
|
+
rel = _code_path(code.co_filename)
|
|
331
|
+
if rel is None:
|
|
332
|
+
return mon.DISABLE
|
|
333
|
+
i = codes.get(code)
|
|
334
|
+
if i is None:
|
|
335
|
+
i = codes[code] = len(table)
|
|
336
|
+
table.append([rel, code.co_firstlineno, code.co_qualname])
|
|
337
|
+
for w in _windows():
|
|
338
|
+
w["codes"].add(i)
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
_error("PY_RESUME", exc)
|
|
341
|
+
return mon.DISABLE
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _on_throw(code, offset, exc):
|
|
345
|
+
# A generator or coroutine re-entered by ``.throw()`` or ``.close()``:
|
|
346
|
+
# resumed like PY_RESUME, but this event cannot be disabled.
|
|
347
|
+
if code not in codes and _code_path(code.co_filename) is None:
|
|
348
|
+
return
|
|
349
|
+
try:
|
|
350
|
+
i = codes.get(code)
|
|
351
|
+
if i is None:
|
|
352
|
+
i = codes[code] = len(table)
|
|
353
|
+
table.append([_code_path(code.co_filename), code.co_firstlineno, code.co_qualname])
|
|
354
|
+
for w in _windows():
|
|
355
|
+
w["codes"].add(i)
|
|
356
|
+
except Exception as err:
|
|
357
|
+
_error("PY_THROW", err)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _on_return(code, offset, retval):
|
|
361
|
+
try:
|
|
362
|
+
if code.co_name == "<module>" and importing:
|
|
363
|
+
importing.pop()
|
|
364
|
+
mon.restart_events() # the importer's own calls are credited to it again
|
|
365
|
+
except Exception as exc:
|
|
366
|
+
_error("PY_RETURN", exc)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _on_unwind(code, offset, exc):
|
|
370
|
+
# An import that raises (``pytest.importorskip`` at module level) ends with
|
|
371
|
+
# an unwind, not a return. This event cannot be disabled: keep it cheap.
|
|
372
|
+
if code.co_name != "<module>" or not importing:
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
if importing[-1] == _code_path(code.co_filename):
|
|
376
|
+
importing.pop()
|
|
377
|
+
mon.restart_events()
|
|
378
|
+
except Exception as err:
|
|
379
|
+
_error("PY_UNWIND", err)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# --------------------------------------------------------------------------- paths
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# Loader methods that read a package's data for its caller
|
|
386
|
+
# (``pkgutil.get_data``, ``importlib.resources``), not to import a module.
|
|
387
|
+
DATA_READERS = frozenset({"get_data", "open_resource", "read_binary", "read_text"})
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _actor() -> str:
|
|
391
|
+
"""Who touched a path: ``import`` when the import system did (it finds
|
|
392
|
+
modules, which the index covers), ``project`` when project code is on
|
|
393
|
+
the stack above it, ``other`` for pytest or a library acting alone
|
|
394
|
+
(collection walks and stats every directory and file)."""
|
|
395
|
+
frame = sys._getframe(2)
|
|
396
|
+
while frame is not None:
|
|
397
|
+
name = frame.f_code.co_filename
|
|
398
|
+
if name.startswith("<frozen importlib") and frame.f_code.co_name not in DATA_READERS:
|
|
399
|
+
return "import"
|
|
400
|
+
if _relative(name) is not None:
|
|
401
|
+
return "project"
|
|
402
|
+
frame = frame.f_back
|
|
403
|
+
return "other"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _touch(path, listing: bool = False) -> None:
|
|
407
|
+
if isinstance(path, int) or path is None:
|
|
408
|
+
return
|
|
409
|
+
try:
|
|
410
|
+
path = os.fsdecode(os.fspath(path))
|
|
411
|
+
except TypeError:
|
|
412
|
+
return
|
|
413
|
+
rel = _relative(os.path.abspath(path))
|
|
414
|
+
if rel is None:
|
|
415
|
+
return
|
|
416
|
+
actor = _actor()
|
|
417
|
+
if actor == "import":
|
|
418
|
+
return
|
|
419
|
+
if active:
|
|
420
|
+
# Inside a test anything counts: a library or plugin reading a file
|
|
421
|
+
# for the test (a data-directory fixture copying files) included.
|
|
422
|
+
for w in active:
|
|
423
|
+
w["dirs" if listing else "paths"].add(rel)
|
|
424
|
+
elif actor == "project":
|
|
425
|
+
# Outside every test: project code at import (a parametrize list
|
|
426
|
+
# globbed from a directory) or in a hook, credited to the module
|
|
427
|
+
# being imported ("" for none).
|
|
428
|
+
seen = import_dirs if listing else import_paths
|
|
429
|
+
seen.setdefault(rel, set()).add(importing[-1] if importing else "")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _audit(event, args):
|
|
433
|
+
try:
|
|
434
|
+
if event == "open":
|
|
435
|
+
if args:
|
|
436
|
+
_touch(args[0])
|
|
437
|
+
elif event in LISTING_EVENTS:
|
|
438
|
+
_touch(args[0] if args and args[0] is not None else ".", listing=True)
|
|
439
|
+
elif event in C_OPEN_EVENTS:
|
|
440
|
+
if args and isinstance(args[0], (str, bytes, os.PathLike)):
|
|
441
|
+
_touch(args[0])
|
|
442
|
+
elif event in SUBPROCESS_EVENTS:
|
|
443
|
+
for w in _windows():
|
|
444
|
+
w["flags"] |= FLAG_SUBPROCESS
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
_error(f"audit {event}", exc)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _flag_multiprocessing_spawns() -> None:
|
|
450
|
+
"""``multiprocessing``'s spawn and forkserver start methods launch their
|
|
451
|
+
children through ``_posixsubprocess.fork_exec``, which raises no audit
|
|
452
|
+
event: flag the test from the function they all go through. A
|
|
453
|
+
subinterpreter (``concurrent.interpreters``, Python 3.14) raises none
|
|
454
|
+
either, and runs code this interpreter's monitoring does not see."""
|
|
455
|
+
for module, name in (("multiprocessing.util", "spawnv_passfds"), ("_interpreters", "create")):
|
|
456
|
+
try:
|
|
457
|
+
_flag_calls(importlib.import_module(module), name)
|
|
458
|
+
except ImportError: # not on this platform or version
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _flag_calls(module, name: str) -> None:
|
|
463
|
+
original = getattr(module, name, None)
|
|
464
|
+
if original is None or getattr(original, "__diffcone__", False):
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
@functools.wraps(original)
|
|
468
|
+
def flagged(*args, **kwargs):
|
|
469
|
+
for w in _windows():
|
|
470
|
+
w["flags"] |= FLAG_SUBPROCESS
|
|
471
|
+
return original(*args, **kwargs)
|
|
472
|
+
|
|
473
|
+
setattr(flagged, "__diffcone__", True) # noqa: B010
|
|
474
|
+
setattr(module, name, flagged)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _wrap_stat(original):
|
|
478
|
+
@functools.wraps(original)
|
|
479
|
+
def stat(path, *args, **kwargs):
|
|
480
|
+
if recording:
|
|
481
|
+
try:
|
|
482
|
+
_touch(path)
|
|
483
|
+
except Exception as exc:
|
|
484
|
+
_error("stat", exc)
|
|
485
|
+
return original(path, *args, **kwargs)
|
|
486
|
+
|
|
487
|
+
# shutil decides at import whether it can use fd-based functions by
|
|
488
|
+
# checking membership in these sets; keep the answer the same.
|
|
489
|
+
for supported in (
|
|
490
|
+
os.supports_dir_fd,
|
|
491
|
+
os.supports_fd,
|
|
492
|
+
os.supports_follow_symlinks,
|
|
493
|
+
os.supports_effective_ids,
|
|
494
|
+
):
|
|
495
|
+
if original in supported:
|
|
496
|
+
supported.add(stat)
|
|
497
|
+
return stat
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# --------------------------------------------------------------------------- caches
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _project_function(func) -> bool:
|
|
504
|
+
code = getattr(getattr(func, "__wrapped__", None), "__code__", None)
|
|
505
|
+
return code is not None and _relative(code.co_filename) is not None
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
_lru_cache = functools.lru_cache
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _tracking_lru_cache(*args, **kwargs):
|
|
512
|
+
"""``functools.lru_cache`` that remembers the caches it makes for project
|
|
513
|
+
code, so caches created after collection (lazy imports) are cleared too.
|
|
514
|
+
Only decoration goes through here; calls to a cached function do not."""
|
|
515
|
+
made = _lru_cache(*args, **kwargs)
|
|
516
|
+
if isinstance(made, functools._lru_cache_wrapper):
|
|
517
|
+
if _project_function(made):
|
|
518
|
+
caches.append(made)
|
|
519
|
+
return made
|
|
520
|
+
|
|
521
|
+
def decorate(func):
|
|
522
|
+
wrapper = made(func)
|
|
523
|
+
if _project_function(wrapper):
|
|
524
|
+
caches.append(wrapper)
|
|
525
|
+
return wrapper
|
|
526
|
+
|
|
527
|
+
return decorate
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# --------------------------------------------------------------------------- writer
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def fold_nodeid(nodeid: str) -> str:
|
|
534
|
+
"""Drop the parameter case, exactly as ``execution.fold_nodeid`` does."""
|
|
535
|
+
return re.sub(r"\[.*\]$", "", nodeid)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
class _Writer:
|
|
539
|
+
def __init__(self, directory: str) -> None:
|
|
540
|
+
self.f = open(os.path.join(directory, f"tests-{os.getpid()}.bin"), "wb")
|
|
541
|
+
self.current: str | None = None
|
|
542
|
+
self.acc = _window()
|
|
543
|
+
|
|
544
|
+
def add(self, test_id: str, w: dict) -> None:
|
|
545
|
+
if test_id != self.current:
|
|
546
|
+
self.flush()
|
|
547
|
+
self.current = test_id
|
|
548
|
+
self.acc["codes"] |= w["codes"]
|
|
549
|
+
self.acc["paths"] |= w["paths"]
|
|
550
|
+
self.acc["dirs"] |= w["dirs"]
|
|
551
|
+
self.acc["flags"] |= w["flags"]
|
|
552
|
+
self.acc["fixtures"] |= w["fixtures"]
|
|
553
|
+
|
|
554
|
+
def flush(self) -> None:
|
|
555
|
+
if self.current is None:
|
|
556
|
+
return
|
|
557
|
+
payload = json.dumps(
|
|
558
|
+
{
|
|
559
|
+
"codes": sorted(self.acc["codes"]),
|
|
560
|
+
"paths": sorted(self.acc["paths"]),
|
|
561
|
+
"dirs": sorted(self.acc["dirs"]),
|
|
562
|
+
"flags": self.acc["flags"],
|
|
563
|
+
"fixtures": sorted(self.acc["fixtures"]),
|
|
564
|
+
}
|
|
565
|
+
).encode()
|
|
566
|
+
name = self.current.encode()
|
|
567
|
+
blob = zlib.compress(payload, 6)
|
|
568
|
+
self.f.write(struct.pack("<II", len(name), len(blob)) + name + blob)
|
|
569
|
+
self.current, self.acc = None, _window()
|
|
570
|
+
|
|
571
|
+
def close(self) -> None:
|
|
572
|
+
self.flush()
|
|
573
|
+
self.f.close()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
writer: _Writer | None = None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# --------------------------------------------------------------------------- start
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _start() -> None:
|
|
583
|
+
"""Start at import of this plugin: ``-p`` plugins load before the initial
|
|
584
|
+
conftests, which usually import the project, so its import-time code is
|
|
585
|
+
seen."""
|
|
586
|
+
global recording
|
|
587
|
+
if mon is None:
|
|
588
|
+
errors.append(f"Python {sys.version.split()[0]} has no sys.monitoring; 3.12+ is needed")
|
|
589
|
+
return
|
|
590
|
+
try:
|
|
591
|
+
mon.use_tool_id(TOOL, "diffcone")
|
|
592
|
+
except ValueError as exc:
|
|
593
|
+
errors.append(f"sys.monitoring tool id {TOOL} is taken ({exc}); nothing was recorded")
|
|
594
|
+
return
|
|
595
|
+
mon.register_callback(TOOL, mon.events.PY_START, _on_start)
|
|
596
|
+
mon.register_callback(TOOL, mon.events.PY_RESUME, _on_resume)
|
|
597
|
+
mon.register_callback(TOOL, mon.events.PY_RETURN, _on_return)
|
|
598
|
+
mon.register_callback(TOOL, mon.events.PY_UNWIND, _on_unwind)
|
|
599
|
+
mon.register_callback(TOOL, mon.events.PY_THROW, _on_throw)
|
|
600
|
+
mon.set_events(
|
|
601
|
+
TOOL,
|
|
602
|
+
mon.events.PY_START | mon.events.PY_RESUME | mon.events.PY_UNWIND | mon.events.PY_THROW,
|
|
603
|
+
)
|
|
604
|
+
sys.addaudithook(_audit)
|
|
605
|
+
os.stat = _wrap_stat(os.stat)
|
|
606
|
+
os.lstat = _wrap_stat(os.lstat)
|
|
607
|
+
os.access = _wrap_stat(os.access)
|
|
608
|
+
# functools.cache goes through it too; a deliberate patch of the stdlib.
|
|
609
|
+
functools.lru_cache = _tracking_lru_cache # ty: ignore[invalid-assignment]
|
|
610
|
+
_flag_multiprocessing_spawns()
|
|
611
|
+
recording = True
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# --------------------------------------------------------------------------- hooks
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def pytest_configure(config):
|
|
618
|
+
global writer
|
|
619
|
+
expected = os.environ.get("DIFFCONE_CHECK_ENV")
|
|
620
|
+
if expected:
|
|
621
|
+
env = environment()
|
|
622
|
+
match = environment_hash(env) == expected
|
|
623
|
+
# Written whether or not it matches: a run without a report was not
|
|
624
|
+
# checked, and diffcone does not trust its evidence plan.
|
|
625
|
+
report = os.environ.get("DIFFCONE_CHECK_REPORT")
|
|
626
|
+
if report:
|
|
627
|
+
with open(report, "w") as f:
|
|
628
|
+
json.dump({"match": match, "environment": env}, f)
|
|
629
|
+
if not match:
|
|
630
|
+
pytest.exit(
|
|
631
|
+
"diffcone: the environment differs from the one the evidence was recorded in",
|
|
632
|
+
returncode=4,
|
|
633
|
+
)
|
|
634
|
+
if os.environ.get("DIFFCONE_CHECK_ONLY"):
|
|
635
|
+
pytest.exit("diffcone: the environment matches the evidence", returncode=0)
|
|
636
|
+
if OUT is not None:
|
|
637
|
+
try:
|
|
638
|
+
os.makedirs(OUT, exist_ok=True)
|
|
639
|
+
writer = _Writer(OUT)
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
_error("configure", exc)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
@pytest.hookimpl(wrapper=True)
|
|
645
|
+
def pytest_collection_modifyitems(session, config, items):
|
|
646
|
+
# Every test pytest collected, before any plugin (``-m``, ``-k``, the
|
|
647
|
+
# selection plugin) deselects: it settles whether discovery's target list
|
|
648
|
+
# is short of the real collection (roadmap item 9).
|
|
649
|
+
global collection_ran
|
|
650
|
+
if recording:
|
|
651
|
+
try:
|
|
652
|
+
collected.update(fold_nodeid(item.nodeid) for item in items)
|
|
653
|
+
collection_ran = True
|
|
654
|
+
except Exception as exc:
|
|
655
|
+
_error("collection_modifyitems", exc)
|
|
656
|
+
result = yield
|
|
657
|
+
# After every other plugin has ordered them.
|
|
658
|
+
if os.environ.get("DIFFCONE_COLLECT_REVERSE"):
|
|
659
|
+
items.reverse()
|
|
660
|
+
return result
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def pytest_collection_finish(session):
|
|
664
|
+
if not recording:
|
|
665
|
+
return
|
|
666
|
+
try:
|
|
667
|
+
# Caches made before this plugin loaded; later ones register themselves.
|
|
668
|
+
known = {id(c) for c in caches}
|
|
669
|
+
caches.extend(
|
|
670
|
+
o
|
|
671
|
+
for o in gc.get_objects()
|
|
672
|
+
if isinstance(o, functools._lru_cache_wrapper)
|
|
673
|
+
and id(o) not in known
|
|
674
|
+
and _project_function(o)
|
|
675
|
+
)
|
|
676
|
+
except Exception as exc:
|
|
677
|
+
_error("collection_finish", exc)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@pytest.hookimpl(wrapper=True)
|
|
681
|
+
def pytest_fixture_setup(fixturedef, request):
|
|
682
|
+
if not recording or fixturedef.scope == "function":
|
|
683
|
+
return (yield)
|
|
684
|
+
key = (fixturedef.baseid, fixturedef.argname, fixturedef.scope)
|
|
685
|
+
w = fixture_windows.setdefault(key, _window())
|
|
686
|
+
active.append(w)
|
|
687
|
+
# Code this test already ran is disabled; re-arm so the fixture's own
|
|
688
|
+
# window sees everything its setup runs.
|
|
689
|
+
mon.restart_events()
|
|
690
|
+
try:
|
|
691
|
+
return (yield)
|
|
692
|
+
finally:
|
|
693
|
+
_drop(w)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def fixture_key(key: tuple[str, str, str]) -> str:
|
|
697
|
+
return "\x1f".join(key)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _drop(window: dict) -> None:
|
|
701
|
+
for i in range(len(active) - 1, -1, -1):
|
|
702
|
+
if active[i] is window:
|
|
703
|
+
del active[i]
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@pytest.hookimpl(tryfirst=True)
|
|
708
|
+
def pytest_runtest_teardown(item, nextitem):
|
|
709
|
+
# Every fixture the test activated, ``request.getfixturevalue`` included;
|
|
710
|
+
# pytest drops the request when the protocol ends, so read it now.
|
|
711
|
+
if not recording:
|
|
712
|
+
return
|
|
713
|
+
try:
|
|
714
|
+
request = getattr(item, "_request", None)
|
|
715
|
+
if request is not None:
|
|
716
|
+
used_fixtures[item.nodeid] = list(request._fixture_defs.values())
|
|
717
|
+
except Exception as exc:
|
|
718
|
+
_error("runtest_teardown", exc)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@pytest.hookimpl(wrapper=True)
|
|
722
|
+
def pytest_runtest_protocol(item, nextitem):
|
|
723
|
+
if not recording:
|
|
724
|
+
return (yield)
|
|
725
|
+
for cache in caches:
|
|
726
|
+
try:
|
|
727
|
+
cache.cache_clear()
|
|
728
|
+
except Exception:
|
|
729
|
+
pass
|
|
730
|
+
mine = _window()
|
|
731
|
+
active.append(mine)
|
|
732
|
+
importing.clear() # no import is running when a test starts
|
|
733
|
+
threads = set(threading.enumerate())
|
|
734
|
+
mon.restart_events()
|
|
735
|
+
try:
|
|
736
|
+
return (yield)
|
|
737
|
+
finally:
|
|
738
|
+
_drop(mine)
|
|
739
|
+
mon.restart_events()
|
|
740
|
+
try:
|
|
741
|
+
# A thread the test started that is still running runs code no
|
|
742
|
+
# record names (between tests, or credited to later ones): the
|
|
743
|
+
# test is flagged like one that started a subprocess.
|
|
744
|
+
if any(t.is_alive() and t not in threads for t in threading.enumerate()):
|
|
745
|
+
mine["flags"] |= FLAG_SUBPROCESS
|
|
746
|
+
except Exception as exc:
|
|
747
|
+
_error("threads", exc)
|
|
748
|
+
try:
|
|
749
|
+
defs = list(used_fixtures.pop(item.nodeid, ()))
|
|
750
|
+
info = getattr(item, "_fixtureinfo", None)
|
|
751
|
+
for name in getattr(item, "fixturenames", ()):
|
|
752
|
+
if info is not None:
|
|
753
|
+
defs.extend(info.name2fixturedefs.get(name, ()))
|
|
754
|
+
for fixturedef in defs:
|
|
755
|
+
if fixturedef.scope != "function":
|
|
756
|
+
key = (fixturedef.baseid, fixturedef.argname, fixturedef.scope)
|
|
757
|
+
mine["fixtures"].add(fixture_key(key))
|
|
758
|
+
shared = fixture_windows.get(key)
|
|
759
|
+
if shared:
|
|
760
|
+
mine["codes"] |= shared["codes"]
|
|
761
|
+
mine["paths"] |= shared["paths"]
|
|
762
|
+
mine["dirs"] |= shared["dirs"]
|
|
763
|
+
mine["flags"] |= shared["flags"]
|
|
764
|
+
if writer is not None:
|
|
765
|
+
writer.add(fold_nodeid(item.nodeid), mine)
|
|
766
|
+
except Exception as exc:
|
|
767
|
+
_error("runtest_protocol", exc)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@pytest.hookimpl(trylast=True)
|
|
771
|
+
def pytest_sessionfinish(session):
|
|
772
|
+
# After pytest's own teardown of session fixtures (an ordinary hook), and
|
|
773
|
+
# before pytest-xdist's wrapper reports the worker finished: the
|
|
774
|
+
# controller then gives each worker ten seconds to exit and kills the
|
|
775
|
+
# rest, so a record written at unconfigure could be cut short on a
|
|
776
|
+
# loaded machine. What runs later (terminal summary, unconfigure) runs
|
|
777
|
+
# after every test and is not recorded.
|
|
778
|
+
_finish()
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def pytest_unconfigure(config):
|
|
782
|
+
_finish() # a session that never started (usage error) still reports
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _finish():
|
|
786
|
+
global finished
|
|
787
|
+
if OUT is None or finished:
|
|
788
|
+
return
|
|
789
|
+
finished = True
|
|
790
|
+
if recording:
|
|
791
|
+
mon.set_events(TOOL, 0)
|
|
792
|
+
for event in (
|
|
793
|
+
mon.events.PY_START,
|
|
794
|
+
mon.events.PY_RESUME,
|
|
795
|
+
mon.events.PY_RETURN,
|
|
796
|
+
mon.events.PY_UNWIND,
|
|
797
|
+
mon.events.PY_THROW,
|
|
798
|
+
):
|
|
799
|
+
mon.register_callback(TOOL, event, None)
|
|
800
|
+
mon.free_tool_id(TOOL)
|
|
801
|
+
try:
|
|
802
|
+
if writer is not None:
|
|
803
|
+
writer.close()
|
|
804
|
+
except Exception as exc:
|
|
805
|
+
_error("close", exc)
|
|
806
|
+
outside = []
|
|
807
|
+
for name, module in list(sys.modules.items()):
|
|
808
|
+
if name.split(".", 1)[0] not in PACKAGES:
|
|
809
|
+
continue
|
|
810
|
+
file = getattr(module, "__file__", None)
|
|
811
|
+
if file and _relative(os.path.abspath(file)) is None:
|
|
812
|
+
outside.append([name, file])
|
|
813
|
+
env = environment()
|
|
814
|
+
record = {
|
|
815
|
+
"pid": os.getpid(),
|
|
816
|
+
"table": table,
|
|
817
|
+
"import_phase": sorted(import_window["codes"]),
|
|
818
|
+
"import_paths": {p: sorted(m) for p, m in sorted(import_paths.items())},
|
|
819
|
+
"import_dirs": {p: sorted(m) for p, m in sorted(import_dirs.items())},
|
|
820
|
+
"import_flags": import_window["flags"],
|
|
821
|
+
"import_by": {str(k): sorted(v) for k, v in import_by.items()},
|
|
822
|
+
"hook_phase": sorted(hook_codes),
|
|
823
|
+
"outside_modules": sorted(outside),
|
|
824
|
+
# What each shared fixture ran here: a fixture computed once for
|
|
825
|
+
# every worker (a file lock and a cache file) runs its code in one.
|
|
826
|
+
"fixtures": {
|
|
827
|
+
fixture_key(key): {
|
|
828
|
+
"codes": sorted(w["codes"]),
|
|
829
|
+
"paths": sorted(w["paths"]),
|
|
830
|
+
"dirs": sorted(w["dirs"]),
|
|
831
|
+
"flags": w["flags"],
|
|
832
|
+
}
|
|
833
|
+
for key, w in sorted(fixture_windows.items())
|
|
834
|
+
},
|
|
835
|
+
"environment": env,
|
|
836
|
+
"environment_hash": environment_hash(env),
|
|
837
|
+
"wrote_tests": writer is not None,
|
|
838
|
+
"collected": sorted(collected) if collection_ran else None,
|
|
839
|
+
"errors": errors,
|
|
840
|
+
}
|
|
841
|
+
with open(os.path.join(OUT, f"process-{os.getpid()}.json"), "w") as f:
|
|
842
|
+
json.dump(record, f)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
if OUT is not None:
|
|
846
|
+
_start()
|