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/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()