metapathology 0.1.0__tar.gz

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,84 @@
1
+ Metadata-Version: 2.3
2
+ Name: metapathology
3
+ Version: 0.1.0
4
+ Summary: sys.meta_path diagnostics: find out who is messing with Python's import machinery
5
+ Author: Glinte
6
+ Author-email: Glinte <96855131+Glinte@users.noreply.github.com>
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # metapathology
11
+
12
+ **sys.meta_path diagnostics** — a tiny, stdlib-only debug tool that tells you
13
+ *who is messing with Python's import machinery*: which finder claimed each
14
+ import, who mutated `sys.meta_path` (and when, with a stack trace), and which
15
+ modules were loaded in ways that bypass `sys.path_hooks`.
16
+
17
+ Built for debugging **import hook contention** — the class of bug where two
18
+ libraries (e.g. an editable-install finder and a `sys.path_hooks`-based
19
+ instrumenter like `beartype.claw`) both want a say in how a module is loaded,
20
+ and one silently loses. See
21
+ [beartype#556](https://github.com/beartype/beartype/issues/556) for the
22
+ motivating incident.
23
+
24
+ ## Status
25
+
26
+ First working version: all three layers, CLI, and exit report are implemented
27
+ and tested. Published to PyPI.
28
+
29
+ ## Usage
30
+
31
+ Primary: run your program under observation, no code changes needed —
32
+
33
+ ```console
34
+ $ python -m metapathology myscript.py --my-args
35
+ $ python -m metapathology -m pytest tests/
36
+ ```
37
+
38
+ A report is printed at exit. Prefer `python -m metapathology` over a bare
39
+ `metapathology` command: it guarantees the hooks land in the same interpreter
40
+ and venv as the code under investigation.
41
+
42
+ Library API, for when a wrapper isn't possible (notebooks, embedded
43
+ interpreters, "I can only touch `conftest.py`"):
44
+
45
+ ```python
46
+ import metapathology
47
+
48
+ metapathology.install() # as early as possible
49
+ ```
50
+
51
+ No dependencies, no configuration required. It's a debug tool: run it, read
52
+ the report, remove it.
53
+
54
+ ## How it works
55
+
56
+ Three layers, escalating in invasiveness, covering each other's blind spots:
57
+
58
+ 1. **Audit hook** (`sys.addaudithook`, passive, irremovable) — snapshots
59
+ `sys.meta_path` on every `import` event; detects wholesale reassignment
60
+ (`sys.meta_path = [...]`), which nothing else can catch.
61
+ 2. **Instrumented `sys.meta_path`** — a `list` subclass whose mutation methods
62
+ log *who* appended/inserted/removed finders, with stack traces, at mutation
63
+ time. Re-installed automatically if layer 1 sees it blown away.
64
+ 3. **Finder attribution** — each finder's `find_spec` is shadowed with a
65
+ logging wrapper in its instance dict (so third-party `isinstance` scans of
66
+ `sys.meta_path` still pass), recording exactly which finder claimed each
67
+ module.
68
+
69
+ The exit report cross-references this log against `sys.modules` and a fresh
70
+ `PathFinder.find_spec()` replay to flag modules that a meta-path finder
71
+ short-circuited away from the standard `sys.path_hooks` chain.
72
+
73
+ ## Caveats
74
+
75
+ - CPython only (relies on the `import` audit event and import-system
76
+ internals).
77
+ - A debug tool by design: it deliberately perturbs `sys.meta_path` in
78
+ reversible ways. Don't ship it enabled in production.
79
+
80
+ ## Related search terms
81
+
82
+ sys.meta_path diagnostics, import hook conflict, PEP 302 compliance, meta path
83
+ finder debugging, sys.path_hooks bypass, editable install import hook,
84
+ who imported this module.
@@ -0,0 +1,75 @@
1
+ # metapathology
2
+
3
+ **sys.meta_path diagnostics** — a tiny, stdlib-only debug tool that tells you
4
+ *who is messing with Python's import machinery*: which finder claimed each
5
+ import, who mutated `sys.meta_path` (and when, with a stack trace), and which
6
+ modules were loaded in ways that bypass `sys.path_hooks`.
7
+
8
+ Built for debugging **import hook contention** — the class of bug where two
9
+ libraries (e.g. an editable-install finder and a `sys.path_hooks`-based
10
+ instrumenter like `beartype.claw`) both want a say in how a module is loaded,
11
+ and one silently loses. See
12
+ [beartype#556](https://github.com/beartype/beartype/issues/556) for the
13
+ motivating incident.
14
+
15
+ ## Status
16
+
17
+ First working version: all three layers, CLI, and exit report are implemented
18
+ and tested. Published to PyPI.
19
+
20
+ ## Usage
21
+
22
+ Primary: run your program under observation, no code changes needed —
23
+
24
+ ```console
25
+ $ python -m metapathology myscript.py --my-args
26
+ $ python -m metapathology -m pytest tests/
27
+ ```
28
+
29
+ A report is printed at exit. Prefer `python -m metapathology` over a bare
30
+ `metapathology` command: it guarantees the hooks land in the same interpreter
31
+ and venv as the code under investigation.
32
+
33
+ Library API, for when a wrapper isn't possible (notebooks, embedded
34
+ interpreters, "I can only touch `conftest.py`"):
35
+
36
+ ```python
37
+ import metapathology
38
+
39
+ metapathology.install() # as early as possible
40
+ ```
41
+
42
+ No dependencies, no configuration required. It's a debug tool: run it, read
43
+ the report, remove it.
44
+
45
+ ## How it works
46
+
47
+ Three layers, escalating in invasiveness, covering each other's blind spots:
48
+
49
+ 1. **Audit hook** (`sys.addaudithook`, passive, irremovable) — snapshots
50
+ `sys.meta_path` on every `import` event; detects wholesale reassignment
51
+ (`sys.meta_path = [...]`), which nothing else can catch.
52
+ 2. **Instrumented `sys.meta_path`** — a `list` subclass whose mutation methods
53
+ log *who* appended/inserted/removed finders, with stack traces, at mutation
54
+ time. Re-installed automatically if layer 1 sees it blown away.
55
+ 3. **Finder attribution** — each finder's `find_spec` is shadowed with a
56
+ logging wrapper in its instance dict (so third-party `isinstance` scans of
57
+ `sys.meta_path` still pass), recording exactly which finder claimed each
58
+ module.
59
+
60
+ The exit report cross-references this log against `sys.modules` and a fresh
61
+ `PathFinder.find_spec()` replay to flag modules that a meta-path finder
62
+ short-circuited away from the standard `sys.path_hooks` chain.
63
+
64
+ ## Caveats
65
+
66
+ - CPython only (relies on the `import` audit event and import-system
67
+ internals).
68
+ - A debug tool by design: it deliberately perturbs `sys.meta_path` in
69
+ reversible ways. Don't ship it enabled in production.
70
+
71
+ ## Related search terms
72
+
73
+ sys.meta_path diagnostics, import hook conflict, PEP 302 compliance, meta path
74
+ finder debugging, sys.path_hooks bypass, editable install import hook,
75
+ who imported this module.
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "metapathology"
3
+ version = "0.1.0"
4
+ description = "sys.meta_path diagnostics: find out who is messing with Python's import machinery"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Glinte", email = "96855131+Glinte@users.noreply.github.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "basedpyright>=1.31.3",
15
+ "hypothesis>=6.146",
16
+ "prek>=0.2.20",
17
+ "pyrefly>=0.44.0",
18
+ "pytest>=8.3",
19
+ "pytest-cov>=6.3",
20
+ "pytest-timeout>=2.4",
21
+ "ruff>=0.12.11",
22
+ ]
23
+
24
+ [tool.pytest.ini_options]
25
+ addopts = [
26
+ "--strict-config",
27
+ "--strict-markers",
28
+ ]
29
+ filterwarnings = ["error"]
30
+ testpaths = ["tests"]
31
+ timeout = 30
32
+ xfail_strict = true
33
+
34
+ [tool.uv]
35
+ exclude-newer = "7 days"
36
+
37
+ [tool.ruff]
38
+ line-length = 120
39
+ target-version = "py310"
40
+
41
+ [tool.ruff.lint]
42
+ select = [
43
+ "E4",
44
+ "E7",
45
+ "E9",
46
+ "F",
47
+ "I",
48
+ "UP",
49
+ "RUF",
50
+ "PT",
51
+ "RET",
52
+ "SIM",
53
+ "PERF",
54
+ ]
55
+
56
+ [tool.ruff.format]
57
+ docstring-code-format = true
58
+
59
+ [tool.basedpyright]
60
+ pythonVersion = "3.10"
61
+ pythonPlatform = "All"
62
+ typeCheckingMode = "standard"
63
+ include = ["src", "tests"]
64
+ exclude = ["**/__pycache__", "**/.*"]
65
+ strictListInference = true
66
+ strictDictionaryInference = true
67
+ strictSetInference = true
68
+ deprecateTypingAliases = true
69
+ reportMissingParameterType = "error"
70
+ reportUnnecessaryComparison = "error"
71
+ reportUnnecessaryContains = "error"
72
+
73
+ [tool.pyrefly]
74
+ project-includes = ["src/**/*.py", "tests/**/*.py"]
75
+ python-version = "3.10"
76
+
77
+ [tool.coverage.run]
78
+ branch = true
79
+ source = ["metapathology"]
80
+
81
+ [tool.coverage.report]
82
+ show_missing = true
83
+ skip_covered = true
84
+
85
+ [build-system]
86
+ requires = ["uv_build>=0.11.13,<0.12.0"]
87
+ build-backend = "uv_build"
@@ -0,0 +1,36 @@
1
+ """metapathology: sys.meta_path diagnostics.
2
+
3
+ Find out who is messing with Python's import machinery: which finder claimed
4
+ each import, who mutated ``sys.meta_path`` (with stack traces), and which
5
+ modules were loaded in ways that bypass ``sys.path_hooks``.
6
+
7
+ Preferred usage is the CLI: ``python -m metapathology <script.py>`` or
8
+ ``python -m metapathology -m <module>``. The library API below exists for
9
+ cases where a wrapper is impossible (notebooks, conftest.py-only access).
10
+ """
11
+
12
+ from metapathology._monitor import Monitor, get_monitor, install, render_report, report, uninstall
13
+ from metapathology._records import (
14
+ FindSpecCall,
15
+ InternalError,
16
+ MetaPathMutation,
17
+ MetaPathReassignment,
18
+ MonitorEvent,
19
+ )
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "FindSpecCall",
25
+ "InternalError",
26
+ "MetaPathMutation",
27
+ "MetaPathReassignment",
28
+ "Monitor",
29
+ "MonitorEvent",
30
+ "__version__",
31
+ "get_monitor",
32
+ "install",
33
+ "render_report",
34
+ "report",
35
+ "uninstall",
36
+ ]
@@ -0,0 +1,92 @@
1
+ """Command-line entry point: run a script or module under the monitor.
2
+
3
+ Mirrors the invocation style of ``python -m cProfile`` / ``python -m trace``.
4
+ The usual runpy caveats apply: the target runs as ``__main__`` but with a
5
+ slightly different ``__spec__`` than a direct invocation would have.
6
+ """
7
+
8
+ import os
9
+ import runpy
10
+ import sys
11
+ import traceback
12
+
13
+ import metapathology
14
+
15
+ _USAGE = """\
16
+ usage: python -m metapathology <script.py> [args...]
17
+ python -m metapathology -m <module> [args...]
18
+
19
+ Runs the target under the metapathology import-machinery monitor and writes a
20
+ diagnostic report to stderr when the target finishes.
21
+ """
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ """Parse CLI arguments and dispatch to script or module execution.
26
+
27
+ Args:
28
+ argv: Argument list without the program name; defaults to ``sys.argv[1:]``.
29
+
30
+ Returns:
31
+ The process exit code.
32
+ """
33
+ args = sys.argv[1:] if argv is None else argv
34
+ if not args:
35
+ sys.stderr.write(_USAGE)
36
+ return 2
37
+ if args[0] in ("-h", "--help"):
38
+ sys.stdout.write(_USAGE)
39
+ return 0
40
+ if args[0] == "-m":
41
+ if len(args) < 2:
42
+ sys.stderr.write(_USAGE)
43
+ return 2
44
+ return _run(args[1], args[2:], is_module=True)
45
+ return _run(args[0], args[1:], is_module=False)
46
+
47
+
48
+ def _run(target: str, target_args: list[str], *, is_module: bool) -> int:
49
+ """Install the monitor, run the target via runpy, and always write the report.
50
+
51
+ Args:
52
+ target: Script path, or module name when ``is_module`` is true.
53
+ target_args: Arguments the target sees as ``sys.argv[1:]``.
54
+ is_module: Select ``python -m``-style execution instead of a script path.
55
+
56
+ Returns:
57
+ The exit code a direct invocation of the target would produce.
58
+ """
59
+ metapathology.install(report_at_exit=False)
60
+ exit_code = 0
61
+ try:
62
+ sys.argv = [target, *target_args]
63
+ if is_module:
64
+ # Mimic `python -m target`: cwd on sys.path; run_module fixes argv[0].
65
+ sys.path.insert(0, os.getcwd())
66
+ runpy.run_module(target, run_name="__main__", alter_sys=True)
67
+ else:
68
+ # Mimic `python target.py`: the script's directory on sys.path.
69
+ sys.path.insert(0, os.path.dirname(os.path.abspath(target)))
70
+ runpy.run_path(target, run_name="__main__")
71
+ except SystemExit as exc:
72
+ exit_code = _exit_code(exc)
73
+ except BaseException:
74
+ traceback.print_exc()
75
+ exit_code = 1
76
+ finally:
77
+ metapathology.report()
78
+ return exit_code
79
+
80
+
81
+ def _exit_code(exc: SystemExit) -> int:
82
+ """Translate ``SystemExit`` into an exit code, printing string payloads the way the interpreter does."""
83
+ if exc.code is None:
84
+ return 0
85
+ if isinstance(exc.code, int):
86
+ return exc.code
87
+ sys.stderr.write(f"{exc.code}\n")
88
+ return 1
89
+
90
+
91
+ if __name__ == "__main__":
92
+ sys.exit(main())
@@ -0,0 +1,570 @@
1
+ """Core monitor: audit hook, instrumented ``sys.meta_path`` list, finder shadowing.
2
+
3
+ Hot-path rules (see CLAUDE.md): everything needed here is imported at module
4
+ scope; recording code takes ``_record_lock`` only around plain-data appends;
5
+ foreign objects are reduced to type names and ids at capture time.
6
+ """
7
+
8
+ import atexit
9
+ import contextlib
10
+ import functools
11
+ import sys
12
+ import threading
13
+ import traceback
14
+ from typing import TYPE_CHECKING, Any, SupportsIndex, TextIO
15
+
16
+ from metapathology import _report
17
+ from metapathology._records import (
18
+ FindSpecCall,
19
+ InternalError,
20
+ MetaPathMutation,
21
+ MetaPathReassignment,
22
+ MonitorEvent,
23
+ type_name,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable, Iterable
28
+ from types import FrameType
29
+
30
+ from _typeshed.importlib import MetaPathFinderProtocol
31
+ from typing_extensions import Self
32
+
33
+ # Frames captured per event; the report trims further (and filters noise) at display time.
34
+ _STACK_CAPTURE_LIMIT = 20
35
+
36
+
37
+ def _capture_stack(frame: "FrameType") -> traceback.StackSummary:
38
+ """Capture a stack summary from ``frame`` outward without reading source lines.
39
+
40
+ ``lookup_lines=False`` keeps source reading (and any ``__loader__.get_source``
41
+ side effects) out of the import hot path; lines resolve at format time.
42
+
43
+ Args:
44
+ frame: Innermost frame to start walking from.
45
+ """
46
+ return traceback.StackSummary.extract(traceback.walk_stack(frame), limit=_STACK_CAPTURE_LIMIT, lookup_lines=False)
47
+
48
+
49
+ class Monitor:
50
+ """Records import-machinery activity. Use the module-level :func:`install`."""
51
+
52
+ def __init__(self) -> None:
53
+ # Guards _events/_seq/_patched/_skipped. Held only around plain-data
54
+ # reads/writes, never across foreign code.
55
+ self._record_lock = threading.Lock()
56
+ # Serializes install/uninstall/reinstall. Lock order: _reinstall_lock
57
+ # may be held while taking _record_lock, never the other way around.
58
+ self._reinstall_lock = threading.Lock()
59
+ # Per-thread re-entrancy flag (.in_audit) so an import triggered from
60
+ # inside _audit cannot recurse back into it.
61
+ self._local = threading.local()
62
+ # One counter for all record types, so the report can interleave the
63
+ # different event kinds chronologically.
64
+ self._seq = 0
65
+ self._events: list[MonitorEvent] = []
66
+ # Master switch: hooks stay registered after uninstall() (audit hooks
67
+ # are irremovable) but become no-ops when this is False.
68
+ self._enabled = False
69
+ # sys.addaudithook is one-way; remember so a reinstall never stacks a second hook.
70
+ self._audit_installed = False
71
+ # Whether our atexit callback is currently registered (mirrors it for unregister).
72
+ self._report_at_exit = False
73
+ # The exact list object we last put into sys.meta_path. The audit hook
74
+ # compares by identity: a mismatch means someone reassigned sys.meta_path.
75
+ self._instrumented: _InstrumentedMetaPath | None = None
76
+ # Both dicts key by id(finder) and hold strong finder references, keeping ids
77
+ # unique for the process lifetime.
78
+ # id -> (finder, original find_spec). A None original marks an
79
+ # in-progress claim, so concurrent callers cannot double-wrap.
80
+ self._patched: dict[int, tuple[Any, Any]] = {}
81
+ # id -> (finder, human-readable reason it could not be instrumented).
82
+ self._skipped: dict[int, tuple[Any, str]] = {}
83
+ # sys.modules names at install time; the report analyzes only modules
84
+ # imported afterwards.
85
+ self._baseline_modules: frozenset[str] = frozenset()
86
+ # Finder display names at install time, for the report header.
87
+ self._initial_meta_path: tuple[str, ...] = ()
88
+
89
+ @property
90
+ def enabled(self) -> bool:
91
+ """Whether the monitor is currently observing."""
92
+ return self._enabled
93
+
94
+ @property
95
+ def initial_meta_path(self) -> tuple[str, ...]:
96
+ """Finder names in ``sys.meta_path`` at install time."""
97
+ return self._initial_meta_path
98
+
99
+ @property
100
+ def baseline_modules(self) -> frozenset[str]:
101
+ """Names present in ``sys.modules`` at install time."""
102
+ return self._baseline_modules
103
+
104
+ def events(self) -> list[MonitorEvent]:
105
+ """Return a snapshot of all recorded events, in capture order."""
106
+ with self._record_lock:
107
+ return list(self._events)
108
+
109
+ def skipped_finders(self) -> list[tuple[str, str]]:
110
+ """Return ``(finder name, reason)`` for meta-path entries that could not be instrumented."""
111
+ with self._record_lock:
112
+ return [(type_name(finder), reason) for finder, reason in self._skipped.values()]
113
+
114
+ def install(self, *, report_at_exit: bool = True) -> None:
115
+ """Instrument ``sys.meta_path`` and register the import audit hook (idempotent).
116
+
117
+ Args:
118
+ report_at_exit: Also register an atexit callback that writes the
119
+ report to stderr.
120
+ """
121
+ with self._reinstall_lock:
122
+ if self._enabled:
123
+ return
124
+ self._enabled = True
125
+ self._baseline_modules = frozenset(sys.modules)
126
+ current = sys.meta_path
127
+ self._initial_meta_path = tuple(type_name(f) for f in current)
128
+ instrumented = _InstrumentedMetaPath(current, self)
129
+ for finder in list(instrumented):
130
+ self._instrument_finder(finder)
131
+ self._instrumented = instrumented
132
+ sys.meta_path = instrumented
133
+ if not self._audit_installed:
134
+ # Audit hooks are irremovable; _audit goes inert when disabled.
135
+ sys.addaudithook(self._audit)
136
+ self._audit_installed = True
137
+ if report_at_exit and not self._report_at_exit:
138
+ self._report_at_exit = True
139
+ atexit.register(self._report_atexit)
140
+
141
+ def uninstall(self) -> None:
142
+ """Restore a plain ``sys.meta_path`` list and unshadow all finders (idempotent)."""
143
+ with self._reinstall_lock:
144
+ if not self._enabled:
145
+ return
146
+ self._enabled = False
147
+ current = sys.meta_path
148
+ if isinstance(current, _InstrumentedMetaPath):
149
+ sys.meta_path = list(current)
150
+ self._instrumented = None
151
+ for finder, _original in list(self._patched.values()):
152
+ with contextlib.suppress(AttributeError, KeyError, TypeError):
153
+ del finder.__dict__["find_spec"]
154
+ with self._record_lock:
155
+ self._patched.clear()
156
+ self._skipped.clear()
157
+ if self._report_at_exit:
158
+ self._report_at_exit = False
159
+ atexit.unregister(self._report_atexit)
160
+
161
+ def _audit(self, event: str, args: tuple[Any, ...]) -> None:
162
+ """``sys.addaudithook`` callback: detect ``sys.meta_path`` reassignment on each ``import`` event.
163
+
164
+ Audit hooks are irremovable, so this must stay a cheap no-op once the
165
+ monitor is disabled. Exceptions are diverted into the event log because
166
+ an exception escaping an audit hook would abort the user's import.
167
+
168
+ Args:
169
+ event: Audit event name; everything but ``"import"`` is ignored.
170
+ args: Audit event arguments; for ``import`` the first item is the
171
+ name of the module being resolved.
172
+ """
173
+ if event != "import" or not self._enabled:
174
+ return
175
+ if getattr(self._local, "in_audit", False):
176
+ return
177
+ self._local.in_audit = True
178
+ try:
179
+ if sys.meta_path is self._instrumented:
180
+ return
181
+ self._reinstall(args)
182
+ except Exception as exc:
183
+ # An exception escaping an audit hook aborts the user's import.
184
+ self._record_internal_error("audit_hook", exc)
185
+ finally:
186
+ self._local.in_audit = False
187
+
188
+ def _reinstall(self, args: tuple[Any, ...]) -> None:
189
+ """Wrap a foreign ``sys.meta_path`` in fresh instrumentation and record the reassignment.
190
+
191
+ Args:
192
+ args: The ``import`` audit event arguments, used to attribute the
193
+ detection to the import that was in flight.
194
+ """
195
+ with self._reinstall_lock:
196
+ # Re-check: another importing thread may have reinstalled already.
197
+ current = sys.meta_path
198
+ expected = self._instrumented
199
+ if current is expected or expected is None or not self._enabled:
200
+ return
201
+ old_contents = tuple(type_name(f) for f in list(expected))
202
+ new_contents = tuple(type_name(f) for f in list(current))
203
+ replacement = _InstrumentedMetaPath(current, self)
204
+ for finder in list(replacement):
205
+ self._instrument_finder(finder)
206
+ self._instrumented = replacement
207
+ sys.meta_path = replacement
208
+ fullname = args[0] if args and isinstance(args[0], str) else "<unknown>"
209
+ stack = _capture_stack(sys._getframe())
210
+ thread_name = threading.current_thread().name
211
+ with self._record_lock:
212
+ self._seq += 1
213
+ self._events.append(
214
+ MetaPathReassignment(
215
+ seq=self._seq,
216
+ during_import=fullname,
217
+ old_contents=old_contents,
218
+ new_contents=new_contents,
219
+ thread_name=thread_name,
220
+ stack=stack,
221
+ )
222
+ )
223
+
224
+ def _instrument_finder(self, finder: object) -> None:
225
+ """Shadow ``finder.find_spec`` with a recording wrapper in the instance dict (layer 3).
226
+
227
+ Instance-dict shadowing keeps third-party ``isinstance`` scans of
228
+ ``sys.meta_path`` working. Entries that cannot be shadowed (classes,
229
+ ``__slots__`` instances, objects without ``find_spec``) are remembered
230
+ as skipped and attributed by elimination at report time.
231
+
232
+ Args:
233
+ finder: Any ``sys.meta_path`` entry; safe to pass repeatedly.
234
+ """
235
+ finder_id = id(finder)
236
+ with self._record_lock:
237
+ if finder_id in self._patched or finder_id in self._skipped:
238
+ return
239
+ # Claim the id before touching the finder so concurrent callers don't double-wrap.
240
+ self._patched[finder_id] = (finder, None)
241
+ if isinstance(finder, type):
242
+ # Class entries (PathFinder and friends) are shared stdlib state; never mutate them.
243
+ self._skip_finder(finder_id, finder, "class entry; instance-dict shadowing not applicable")
244
+ return
245
+ try:
246
+ original = getattr(finder, "find_spec", None)
247
+ except Exception as exc:
248
+ self._skip_finder(finder_id, finder, "find_spec lookup raised")
249
+ self._record_internal_error("instrument_finder", exc)
250
+ return
251
+ if not callable(original):
252
+ self._skip_finder(finder_id, finder, "no callable find_spec")
253
+ return
254
+ wrapper = self._make_find_spec_wrapper(finder, original)
255
+ try:
256
+ setattr(finder, "find_spec", wrapper)
257
+ except Exception:
258
+ self._skip_finder(finder_id, finder, "find_spec not settable on the instance")
259
+ return
260
+ with self._record_lock:
261
+ self._patched[finder_id] = (finder, original)
262
+
263
+ def _skip_finder(self, finder_id: int, finder: object, reason: str) -> None:
264
+ """Mark a finder as uninstrumentable, releasing any claim taken by ``_instrument_finder``.
265
+
266
+ Args:
267
+ finder_id: ``id(finder)``, the claim key.
268
+ finder: The entry itself; kept referenced so its id stays unique.
269
+ reason: Human-readable explanation shown in the report.
270
+ """
271
+ with self._record_lock:
272
+ self._patched.pop(finder_id, None)
273
+ self._skipped[finder_id] = (finder, reason)
274
+
275
+ def _make_find_spec_wrapper(self, finder: object, original: "Callable[..., Any]") -> "Callable[..., Any]":
276
+ """Build the ``find_spec`` replacement that records each call and delegates to the original.
277
+
278
+ Args:
279
+ finder: The finder being shadowed, for attribution.
280
+ original: The bound ``find_spec`` to delegate to.
281
+
282
+ Returns:
283
+ A plain function suitable for the finder's instance dict; functions
284
+ stored there are not bound, so it takes no ``self``.
285
+ """
286
+
287
+ @functools.wraps(original)
288
+ def find_spec(fullname: str, path: Any = None, target: Any = None) -> Any:
289
+ spec = None
290
+ exception_type_name: str | None = None
291
+ try:
292
+ spec = original(fullname, path, target)
293
+ except BaseException as exc:
294
+ exception_type_name = type(exc).__name__
295
+ raise
296
+ finally:
297
+ self._record_find_spec(finder, fullname, spec, exception_type_name)
298
+ return spec
299
+
300
+ return find_spec
301
+
302
+ def _record_find_spec(self, finder: object, fullname: str, spec: Any, exception_type_name: str | None) -> None:
303
+ """Record one ``find_spec`` call, reducing the spec to primitives before taking the lock.
304
+
305
+ Args:
306
+ finder: The finder that was called.
307
+ fullname: The module name that was probed.
308
+ spec: Whatever ``find_spec`` returned; ``None`` means the finder passed.
309
+ exception_type_name: Set when the call raised instead of returning.
310
+ """
311
+ try:
312
+ loader_type_name: str | None = None
313
+ origin: str | None = None
314
+ if spec is not None:
315
+ loader = getattr(spec, "loader", None)
316
+ if loader is not None:
317
+ loader_type_name = type_name(loader)
318
+ raw_origin = getattr(spec, "origin", None)
319
+ if isinstance(raw_origin, str):
320
+ origin = raw_origin
321
+ finder_type_name = type_name(finder)
322
+ finder_id = id(finder)
323
+ found = spec is not None
324
+ thread_name = threading.current_thread().name
325
+ with self._record_lock:
326
+ self._seq += 1
327
+ self._events.append(
328
+ FindSpecCall(
329
+ seq=self._seq,
330
+ fullname=fullname,
331
+ finder_type_name=finder_type_name,
332
+ finder_id=finder_id,
333
+ found=found,
334
+ loader_type_name=loader_type_name,
335
+ origin=origin,
336
+ exception_type_name=exception_type_name,
337
+ thread_name=thread_name,
338
+ )
339
+ )
340
+ except Exception as exc:
341
+ self._record_internal_error("record_find_spec", exc)
342
+
343
+ def _on_meta_path_mutation(
344
+ self,
345
+ mutated: "_InstrumentedMetaPath",
346
+ op: str,
347
+ added: tuple[object, ...],
348
+ removed: tuple[object, ...],
349
+ frame: "FrameType",
350
+ ) -> None:
351
+ """Record a mutation of the instrumented list and instrument any newly added finders.
352
+
353
+ Called by every overridden mutator of ``_InstrumentedMetaPath``.
354
+
355
+ Args:
356
+ mutated: The list that was mutated (not necessarily the live
357
+ ``sys.meta_path`` anymore, after a reassignment).
358
+ op: Name of the mutating method, e.g. ``"insert"``.
359
+ added: Finders the mutation added.
360
+ removed: Finders the mutation removed.
361
+ frame: The mutator's caller, where the stack capture starts.
362
+ """
363
+ try:
364
+ if not self._enabled:
365
+ return
366
+ for finder in added:
367
+ self._instrument_finder(finder)
368
+ added_names = tuple(type_name(f) for f in added)
369
+ removed_names = tuple(type_name(f) for f in removed)
370
+ contents_after = tuple(type_name(f) for f in list(mutated))
371
+ thread_name = threading.current_thread().name
372
+ stack = _capture_stack(frame)
373
+ with self._record_lock:
374
+ self._seq += 1
375
+ self._events.append(
376
+ MetaPathMutation(
377
+ seq=self._seq,
378
+ op=op,
379
+ added=added_names,
380
+ removed=removed_names,
381
+ contents_after=contents_after,
382
+ thread_name=thread_name,
383
+ stack=stack,
384
+ )
385
+ )
386
+ except Exception as exc:
387
+ self._record_internal_error("meta_path_mutation", exc)
388
+
389
+ def _record_internal_error(self, where: str, exc: BaseException) -> None:
390
+ """Record an exception raised by our own instrumentation instead of letting it escape.
391
+
392
+ Args:
393
+ where: Short label of the code path that failed.
394
+ exc: The caught exception; stringified defensively here.
395
+ """
396
+ try:
397
+ message = str(exc)
398
+ except Exception:
399
+ message = "<unprintable>"
400
+ exception_type_name = type(exc).__name__
401
+ with self._record_lock:
402
+ self._seq += 1
403
+ self._events.append(
404
+ InternalError(seq=self._seq, where=where, exception_type_name=exception_type_name, message=message)
405
+ )
406
+
407
+ def _report_atexit(self) -> None:
408
+ """Write the report to stderr from atexit without ever breaking interpreter shutdown."""
409
+ try:
410
+ _report.write_report(self, sys.stderr)
411
+ except Exception:
412
+ traceback.print_exc()
413
+
414
+
415
+ class _InstrumentedMetaPath(list["MetaPathFinderProtocol"]):
416
+ """Drop-in ``sys.meta_path`` replacement that records mutations.
417
+
418
+ A real ``list`` subclass, never a proxy: third-party code that scans
419
+ ``sys.meta_path`` with ``isinstance``, slices it, or iterates it keeps
420
+ working unchanged. Mutations made from C via ``PyList_*`` bypass these
421
+ overrides; that is an accepted blind spot.
422
+ """
423
+
424
+ __slots__ = ("_monitor",)
425
+
426
+ def __init__(self, contents: "Iterable[MetaPathFinderProtocol]", monitor: Monitor) -> None:
427
+ """Wrap ``contents`` without recording a mutation.
428
+
429
+ Args:
430
+ contents: The finders to start with, typically the previous
431
+ ``sys.meta_path``.
432
+ monitor: The monitor that receives mutation callbacks.
433
+ """
434
+ super().__init__(contents)
435
+ self._monitor = monitor
436
+
437
+ def append(self, item: "MetaPathFinderProtocol") -> None:
438
+ """Delegate to ``list.append`` and record the addition."""
439
+ super().append(item)
440
+ self._monitor._on_meta_path_mutation(self, "append", (item,), (), sys._getframe(1))
441
+
442
+ def insert(self, index: SupportsIndex, item: "MetaPathFinderProtocol") -> None:
443
+ """Delegate to ``list.insert`` and record the addition."""
444
+ super().insert(index, item)
445
+ self._monitor._on_meta_path_mutation(self, "insert", (item,), (), sys._getframe(1))
446
+
447
+ def extend(self, items: "Iterable[MetaPathFinderProtocol]") -> None:
448
+ """Record an ``extend``, materializing ``items`` first so one-shot iterators can be both stored and logged."""
449
+ materialized = tuple(items)
450
+ super().extend(materialized)
451
+ self._monitor._on_meta_path_mutation(self, "extend", materialized, (), sys._getframe(1))
452
+
453
+ def remove(self, item: "MetaPathFinderProtocol") -> None:
454
+ """Delegate to ``list.remove`` and record the removal."""
455
+ super().remove(item)
456
+ self._monitor._on_meta_path_mutation(self, "remove", (), (item,), sys._getframe(1))
457
+
458
+ def pop(self, index: SupportsIndex = -1) -> "MetaPathFinderProtocol":
459
+ """Delegate to ``list.pop`` and record the removal.
460
+
461
+ Returns:
462
+ The removed finder, as ``list.pop`` would.
463
+ """
464
+ item = super().pop(index)
465
+ self._monitor._on_meta_path_mutation(self, "pop", (), (item,), sys._getframe(1))
466
+ return item
467
+
468
+ def clear(self) -> None:
469
+ """Record the removal of the entire contents, then delegate to ``list.clear``."""
470
+ removed = tuple(self)
471
+ super().clear()
472
+ self._monitor._on_meta_path_mutation(self, "clear", (), removed, sys._getframe(1))
473
+
474
+ def reverse(self) -> None:
475
+ """Record an order-only mutation; finder precedence changes even though membership does not."""
476
+ super().reverse()
477
+ self._monitor._on_meta_path_mutation(self, "reverse", (), (), sys._getframe(1))
478
+
479
+ def __setitem__(self, index: SupportsIndex | slice, value: Any) -> None:
480
+ """Record single-item or slice replacement (slice assignment is how many tools filter finders)."""
481
+ if isinstance(index, slice):
482
+ added = tuple(value)
483
+ removed = tuple(self[index])
484
+ super().__setitem__(index, added)
485
+ else:
486
+ added = (value,)
487
+ removed = (self[index],)
488
+ super().__setitem__(index, value)
489
+ self._monitor._on_meta_path_mutation(self, "__setitem__", added, removed, sys._getframe(1))
490
+
491
+ def __delitem__(self, index: SupportsIndex | slice) -> None:
492
+ """Delegate to ``list.__delitem__`` and record the removed finder(s)."""
493
+ removed = tuple(self[index]) if isinstance(index, slice) else (self[index],)
494
+ super().__delitem__(index)
495
+ self._monitor._on_meta_path_mutation(self, "__delitem__", (), removed, sys._getframe(1))
496
+
497
+ def __iadd__(self, items: "Iterable[MetaPathFinderProtocol]") -> "Self":
498
+ """Record ``+=``, which reaches ``list`` at the C level and would otherwise bypass the ``extend`` override."""
499
+ materialized = tuple(items)
500
+ super().extend(materialized)
501
+ self._monitor._on_meta_path_mutation(self, "__iadd__", materialized, (), sys._getframe(1))
502
+ return self
503
+
504
+
505
+ # One Monitor per process: the instrumentation targets process-global state
506
+ # (sys.meta_path, the audit hook), so multiple instances would fight each other.
507
+ _monitor_singleton: Monitor | None = None
508
+ _singleton_lock = threading.Lock()
509
+
510
+
511
+ def install(*, report_at_exit: bool = True) -> Monitor:
512
+ """Install the import-machinery monitor (idempotent) and return it.
513
+
514
+ Call as early as possible: only imports and mutations that happen after
515
+ this call are observed.
516
+
517
+ Args:
518
+ report_at_exit: Also register an atexit callback that writes the
519
+ report to stderr.
520
+ """
521
+ global _monitor_singleton
522
+ with _singleton_lock:
523
+ if _monitor_singleton is None:
524
+ _monitor_singleton = Monitor()
525
+ monitor = _monitor_singleton
526
+ monitor.install(report_at_exit=report_at_exit)
527
+ return monitor
528
+
529
+
530
+ def uninstall() -> None:
531
+ """Undo :func:`install`: restore a plain ``sys.meta_path`` and unshadow finders."""
532
+ monitor = get_monitor()
533
+ if monitor is not None:
534
+ monitor.uninstall()
535
+
536
+
537
+ def get_monitor() -> Monitor | None:
538
+ """Return the process-wide monitor, or None if :func:`install` was never called."""
539
+ with _singleton_lock:
540
+ return _monitor_singleton
541
+
542
+
543
+ def report(file: TextIO | None = None) -> None:
544
+ """Write the diagnostic report to ``file`` (default ``sys.stderr``).
545
+
546
+ Raises:
547
+ RuntimeError: If :func:`install` was never called.
548
+ """
549
+ _report.write_report(_require_monitor(), file)
550
+
551
+
552
+ def render_report() -> str:
553
+ """Return the diagnostic report as text.
554
+
555
+ Raises:
556
+ RuntimeError: If :func:`install` was never called.
557
+ """
558
+ return _report.render_report(_require_monitor())
559
+
560
+
561
+ def _require_monitor() -> Monitor:
562
+ """Return the singleton monitor.
563
+
564
+ Raises:
565
+ RuntimeError: If :func:`install` was never called.
566
+ """
567
+ monitor = get_monitor()
568
+ if monitor is None:
569
+ raise RuntimeError("metapathology.install() has not been called")
570
+ return monitor
@@ -0,0 +1,134 @@
1
+ """Plain event records captured by the monitor.
2
+
3
+ Records hold only primitive data (type names, ids, precomputed strings)
4
+ extracted at capture time. Foreign objects are never repr()'d while an import
5
+ may be in flight; all formatting happens at report time in ``_report``.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from traceback import StackSummary
10
+ from typing import TypeAlias
11
+
12
+
13
+ def type_name(obj: object) -> str:
14
+ """Best-effort display name: ``__name__`` for class entries (e.g. ``PathFinder``), type name otherwise."""
15
+ if isinstance(obj, type):
16
+ return obj.__name__
17
+ return type(obj).__name__
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class MetaPathMutation:
22
+ """A mutating method call observed on the instrumented ``sys.meta_path`` list.
23
+
24
+ Attributes:
25
+ seq: Position in the monitor's single event log; one counter is shared
26
+ by all record types so events can be interleaved chronologically.
27
+ op: Name of the list method that mutated the list, e.g. ``"insert"``
28
+ or ``"__setitem__"``.
29
+ added: Display names (via :func:`type_name`) of finders the mutation
30
+ added; empty for pure removals and order changes.
31
+ removed: Display names of finders the mutation removed.
32
+ contents_after: Display names of the whole list right after the
33
+ mutation. This is the mutated list, which after a reassignment is
34
+ not necessarily the live ``sys.meta_path``.
35
+ thread_name: Name of the thread that performed the mutation.
36
+ stack: Caller stack at mutation time, innermost frame first, captured
37
+ without source lines (they are resolved at report time).
38
+ """
39
+
40
+ seq: int
41
+ op: str
42
+ added: tuple[str, ...]
43
+ removed: tuple[str, ...]
44
+ contents_after: tuple[str, ...]
45
+ thread_name: str
46
+ stack: StackSummary
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class MetaPathReassignment:
51
+ """``sys.meta_path`` was replaced wholesale, detected via the ``import`` audit event.
52
+
53
+ Detection granularity is the next import after the fact, so every field
54
+ describes the state *at detection time*, not at the moment of reassignment.
55
+
56
+ Attributes:
57
+ seq: Position in the monitor's single event log (shared counter, see
58
+ :class:`MetaPathMutation`).
59
+ during_import: Name of the module whose import triggered detection.
60
+ The reassignment happened some time before this import.
61
+ old_contents: Display names of the abandoned instrumented list's
62
+ entries at detection time; they may have drifted since the
63
+ reassignment.
64
+ new_contents: Display names of the entries of the list that replaced
65
+ ours.
66
+ thread_name: Name of the thread whose import triggered detection.
67
+ stack: Stack of the triggering import, not of the reassigner (plain
68
+ attribute assignment raises no event, so that stack is unknowable).
69
+ """
70
+
71
+ seq: int
72
+ during_import: str
73
+ old_contents: tuple[str, ...]
74
+ new_contents: tuple[str, ...]
75
+ thread_name: str
76
+ stack: StackSummary
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class FindSpecCall:
81
+ """One ``find_spec`` call on an instrumented meta-path finder.
82
+
83
+ Attributes:
84
+ seq: Position in the monitor's single event log (shared counter, see
85
+ :class:`MetaPathMutation`).
86
+ fullname: The module name that was probed.
87
+ finder_type_name: Display name of the finder that was asked.
88
+ finder_id: ``id()`` of that finder; stable because the monitor keeps a
89
+ strong reference, and needed to tell apart two finders of the same
90
+ type.
91
+ found: True when the finder claimed the module (returned a spec).
92
+ loader_type_name: Display name of ``spec.loader``'s type, or None when
93
+ there is no spec or no loader (e.g. namespace packages).
94
+ origin: ``spec.origin`` when it is a string (a file path for
95
+ filesystem imports), else None.
96
+ exception_type_name: Type name of the exception if ``find_spec``
97
+ raised instead of returning; ``found`` is False in that case.
98
+ thread_name: Name of the thread that ran the import.
99
+ """
100
+
101
+ seq: int
102
+ fullname: str
103
+ finder_type_name: str
104
+ finder_id: int
105
+ found: bool
106
+ loader_type_name: str | None
107
+ origin: str | None
108
+ exception_type_name: str | None
109
+ thread_name: str
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class InternalError:
114
+ """An exception raised inside metapathology's own instrumentation.
115
+
116
+ Recorded instead of raised: an exception escaping a hook would abort the
117
+ user's import, which a diagnostic tool must never do.
118
+
119
+ Attributes:
120
+ seq: Position in the monitor's single event log (shared counter, see
121
+ :class:`MetaPathMutation`).
122
+ where: Short label of the failing code path, e.g. ``"audit_hook"``.
123
+ exception_type_name: Type name of the caught exception.
124
+ message: ``str(exc)``, or a placeholder if stringification failed too.
125
+ """
126
+
127
+ seq: int
128
+ where: str
129
+ exception_type_name: str
130
+ message: str
131
+
132
+
133
+ # Everything the monitor records goes into one chronological log; ``seq`` orders records across types.
134
+ MonitorEvent: TypeAlias = FindSpecCall | InternalError | MetaPathMutation | MetaPathReassignment
@@ -0,0 +1,286 @@
1
+ """Rendering of the diagnostic report.
2
+
3
+ All stringification of foreign data (specs, loaders, stack frames) happens
4
+ here, at report time, never while an import is in flight. The report is
5
+ typically written from an atexit callback, so nothing here may raise.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from importlib.machinery import PathFinder
11
+ from typing import TYPE_CHECKING, TextIO
12
+
13
+ from metapathology._records import (
14
+ FindSpecCall,
15
+ InternalError,
16
+ MetaPathMutation,
17
+ MetaPathReassignment,
18
+ type_name,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from importlib.machinery import ModuleSpec
23
+ from traceback import StackSummary
24
+
25
+ from metapathology._monitor import Monitor
26
+
27
+ # This package's own directory, normcased for comparison: used to drop our
28
+ # frames from displayed stacks.
29
+ _PACKAGE_DIR = os.path.normcase(os.path.dirname(os.path.abspath(__file__)))
30
+ # Only claims with these origin suffixes get the bypass check; extension
31
+ # modules, builtins, and synthetic origins have no PathFinder baseline.
32
+ _SOURCE_SUFFIXES = (".py", ".pyc")
33
+ # Max non-noise frames shown per stack in the report.
34
+ _STACK_DISPLAY_FRAMES = 5
35
+ # Max claimed modules listed per finder in the attribution section.
36
+ _MAX_LISTED_MODULES = 25
37
+
38
+
39
+ def write_report(monitor: "Monitor", file: TextIO | None = None) -> None:
40
+ """Render the report for ``monitor`` and write it to ``file`` (default ``sys.stderr``)."""
41
+ out = sys.stderr if file is None else file
42
+ out.write(render_report(monitor))
43
+
44
+
45
+ def render_report(monitor: "Monitor") -> str:
46
+ """Render the full diagnostic report as text.
47
+
48
+ Never raises; on internal failure the returned text says so instead.
49
+ """
50
+ try:
51
+ return "\n".join(_render_lines(monitor)) + "\n"
52
+ except Exception as exc: # The report must never break the host program.
53
+ return f"== metapathology report ==\nreport generation failed: {type(exc).__name__}: {exc}\n"
54
+
55
+
56
+ def _render_lines(monitor: "Monitor") -> list[str]:
57
+ """Build the report body as a list of lines; the caller joins and appends the trailing newline."""
58
+ events = monitor.events()
59
+ mutations = [e for e in events if isinstance(e, MetaPathMutation)]
60
+ reassignments = [e for e in events if isinstance(e, MetaPathReassignment)]
61
+ calls = [e for e in events if isinstance(e, FindSpecCall)]
62
+ errors = [e for e in events if isinstance(e, InternalError)]
63
+
64
+ lines = ["== metapathology report =="]
65
+ lines.append(f"monitor enabled: {monitor.enabled}")
66
+ lines.append(f"initial sys.meta_path: {_names_line(monitor.initial_meta_path)}")
67
+ lines.append(f"current sys.meta_path: {_names_line(_current_meta_path_names())}")
68
+ skipped = monitor.skipped_finders()
69
+ if skipped:
70
+ lines.append("finders observed but not instrumented (attribution by elimination):")
71
+ lines.extend(f" {name}: {reason}" for name, reason in skipped)
72
+ new_modules = _modules_since_install(monitor)
73
+ lines.append(f"modules imported since install: {len(new_modules)}")
74
+
75
+ lines.append("")
76
+ lines.append(f"-- sys.meta_path mutations ({len(mutations)}) --")
77
+ if not mutations:
78
+ lines.append("(none)")
79
+ for mutation in mutations:
80
+ lines.extend(_mutation_lines(mutation))
81
+
82
+ lines.append("")
83
+ lines.append(f"-- sys.meta_path reassignments ({len(reassignments)}) --")
84
+ if not reassignments:
85
+ lines.append("(none)")
86
+ for reassignment in reassignments:
87
+ lines.extend(_reassignment_lines(reassignment))
88
+
89
+ lines.append("")
90
+ lines.append("-- finder attribution (instrumented finders only) --")
91
+ lines.extend(_attribution_lines(calls))
92
+
93
+ findings = _suspicious_findings(monitor, calls)
94
+ lines.append("")
95
+ lines.append(f"-- suspicious findings ({len(findings)}) --")
96
+ if not findings:
97
+ lines.append("(none)")
98
+ lines.extend(findings)
99
+
100
+ lines.append("")
101
+ lines.append(f"-- internal errors ({len(errors)}) --")
102
+ if not errors:
103
+ lines.append("(none)")
104
+ lines.extend(f"#{e.seq} in {e.where}: {e.exception_type_name}: {e.message}" for e in errors)
105
+
106
+ lines.append("")
107
+ return lines
108
+
109
+
110
+ def _current_meta_path_names() -> tuple[str, ...]:
111
+ """Name the current ``sys.meta_path`` entries, tolerating a broken interpreter state at shutdown."""
112
+ try:
113
+ return tuple(type_name(f) for f in list(sys.meta_path))
114
+ except Exception:
115
+ return ("<unavailable>",)
116
+
117
+
118
+ def _modules_since_install(monitor: "Monitor") -> list[str]:
119
+ """List names added to ``sys.modules`` after the monitor was installed."""
120
+ baseline = monitor.baseline_modules
121
+ try:
122
+ return [name for name in list(sys.modules) if name not in baseline]
123
+ except Exception:
124
+ return []
125
+
126
+
127
+ def _names_line(names: tuple[str, ...]) -> str:
128
+ """Format finder names as a bracketed, comma-separated list."""
129
+ return "[" + ", ".join(names) + "]"
130
+
131
+
132
+ def _mutation_lines(mutation: MetaPathMutation) -> list[str]:
133
+ """Format one mutation record: op, added/removed delta, resulting contents, and user-code stack."""
134
+ delta_parts: list[str] = []
135
+ if mutation.added:
136
+ delta_parts.append("+" + _names_line(mutation.added))
137
+ if mutation.removed:
138
+ delta_parts.append("-" + _names_line(mutation.removed))
139
+ delta = " ".join(delta_parts) if delta_parts else "(order change)"
140
+ lines = [f"#{mutation.seq} {mutation.op} {delta} [thread {mutation.thread_name}]"]
141
+ lines.append(f" meta_path after: {_names_line(mutation.contents_after)}")
142
+ lines.extend(_stack_lines(mutation.stack))
143
+ return lines
144
+
145
+
146
+ def _reassignment_lines(reassignment: MetaPathReassignment) -> list[str]:
147
+ """Format one reassignment record with before/after contents and the detection stack."""
148
+ lines = [
149
+ f"#{reassignment.seq} sys.meta_path REASSIGNED, detected during import of "
150
+ f"'{reassignment.during_import}' [thread {reassignment.thread_name}]"
151
+ ]
152
+ lines.append(f" before: {_names_line(reassignment.old_contents)}")
153
+ lines.append(f" after: {_names_line(reassignment.new_contents)}")
154
+ lines.append(" instrumentation reinstalled; stack shows the triggering import, not the reassignment itself:")
155
+ lines.extend(_stack_lines(reassignment.stack))
156
+ return lines
157
+
158
+
159
+ def _attribution_lines(calls: list[FindSpecCall]) -> list[str]:
160
+ """Summarize ``find_spec`` traffic per finder: probe counts and claimed modules, capped per finder."""
161
+ probes: dict[tuple[str, int], int] = {}
162
+ wins: dict[tuple[str, int], list[str]] = {}
163
+ for call in calls:
164
+ key = (call.finder_type_name, call.finder_id)
165
+ probes[key] = probes.get(key, 0) + 1
166
+ if call.found:
167
+ wins.setdefault(key, []).append(call.fullname)
168
+ if not probes:
169
+ return ["(no find_spec activity recorded on instrumented finders)"]
170
+ lines: list[str] = []
171
+ for (name, finder_id), count in sorted(probes.items()):
172
+ claimed = wins.get((name, finder_id), [])
173
+ lines.append(f"{name} (id 0x{finder_id:x}): {count} find_spec calls, {len(claimed)} claimed")
174
+ lines.extend(f" {module}" for module in claimed[:_MAX_LISTED_MODULES])
175
+ if len(claimed) > _MAX_LISTED_MODULES:
176
+ lines.append(f" ... and {len(claimed) - _MAX_LISTED_MODULES} more")
177
+ return lines
178
+
179
+
180
+ def _suspicious_findings(monitor: "Monitor", calls: list[FindSpecCall]) -> list[str]:
181
+ """Cross-reference ``sys.modules`` against recorded claims and return finding lines.
182
+
183
+ Modules claimed by an instrumented finder get the bypass check; modules
184
+ with no recorded claim and no ``__spec__`` are flagged as manual loads.
185
+ Modules that predate the monitor are ignored.
186
+ """
187
+ winners: dict[str, FindSpecCall] = {}
188
+ for call in calls:
189
+ if call.found:
190
+ winners[call.fullname] = call
191
+ findings: list[str] = []
192
+ baseline = monitor.baseline_modules
193
+ for name, module in list(sys.modules.items()):
194
+ if name in baseline or name == "__main__":
195
+ continue
196
+ winner = winners.get(name)
197
+ if winner is not None:
198
+ findings.extend(_bypass_findings(name, winner))
199
+ continue
200
+ try:
201
+ spec = getattr(module, "__spec__", None)
202
+ except Exception: # sys.modules values can be arbitrarily weird objects.
203
+ spec = None
204
+ if spec is None:
205
+ findings.append(
206
+ f"[no-spec] '{name}' is in sys.modules with no __spec__ and no recorded finder claim "
207
+ "(manually created or exec_module-style load; invisible to all import hooks)."
208
+ )
209
+ return findings
210
+
211
+
212
+ def _bypass_findings(name: str, winner: FindSpecCall) -> list[str]:
213
+ """Check one claimed source module against a fresh ``PathFinder`` replay.
214
+
215
+ Args:
216
+ name: The claimed module's fullname.
217
+ winner: The recorded ``find_spec`` call that claimed it.
218
+
219
+ Returns:
220
+ At most one finding line: ``[bypass]`` when the standard machinery
221
+ would produce a different loader or origin, ``[unfindable]`` when it
222
+ finds nothing at all, empty when the claim looks compliant or has no
223
+ filesystem source origin.
224
+ """
225
+ origin = winner.origin
226
+ if origin is None or not origin.endswith(_SOURCE_SUFFIXES) or winner.loader_type_name is None:
227
+ return []
228
+ replay = _replay_path_finder(name)
229
+ if replay is None:
230
+ return [
231
+ f"[unfindable] '{name}' (origin {origin}) was claimed by {winner.finder_type_name}, but the "
232
+ "standard sys.path machinery cannot find it: sys.path_hooks-based tools never see this module."
233
+ ]
234
+ replay_loader = None if replay.loader is None else type_name(replay.loader)
235
+ replay_origin = replay.origin if isinstance(replay.origin, str) else None
236
+ if replay_loader != winner.loader_type_name or not _same_path(replay_origin, origin):
237
+ return [
238
+ f"[bypass] '{name}' was claimed by {winner.finder_type_name} "
239
+ f"(loader {winner.loader_type_name}, origin {origin}); the standard sys.path machinery would use "
240
+ f"loader {replay_loader} (origin {replay_origin}). sys.path_hooks-based tools were bypassed."
241
+ ]
242
+ return []
243
+
244
+
245
+ def _replay_path_finder(name: str) -> "ModuleSpec | None":
246
+ """Ask the standard ``PathFinder`` what it would do for ``name``, without importing anything.
247
+
248
+ Returns:
249
+ The spec the standard path machinery would produce now, or None when
250
+ it finds nothing or the replay is impossible (e.g. the parent package
251
+ is gone or has no ``__path__``).
252
+ """
253
+ parent_name, _, _ = name.rpartition(".")
254
+ path = None
255
+ if parent_name:
256
+ parent = sys.modules.get(parent_name)
257
+ path = getattr(parent, "__path__", None)
258
+ if path is None:
259
+ return None
260
+ try:
261
+ return PathFinder.find_spec(name, path)
262
+ except Exception: # A broken finder chain must not break the report.
263
+ return None
264
+
265
+
266
+ def _same_path(a: str | None, b: str | None) -> bool:
267
+ """Compare two filesystem paths after absolutization and case normalization."""
268
+ if a is None or b is None:
269
+ return a == b
270
+ return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
271
+
272
+
273
+ def _stack_lines(stack: "StackSummary") -> list[str]:
274
+ """Format the interesting frames of a captured stack, innermost first, noise filtered out."""
275
+ frames = [f for f in stack if not _is_noise_frame(f.filename)]
276
+ shown = frames[:_STACK_DISPLAY_FRAMES] # walk_stack order: innermost first.
277
+ if not shown:
278
+ return [" (no frames outside the import machinery)"]
279
+ return [f" at {f.filename}:{f.lineno} in {f.name}" for f in shown]
280
+
281
+
282
+ def _is_noise_frame(filename: str) -> bool:
283
+ """Return True for frames from the import machinery or from metapathology itself."""
284
+ if filename.startswith("<frozen importlib"):
285
+ return True
286
+ return os.path.normcase(os.path.abspath(filename)).startswith(_PACKAGE_DIR)
File without changes