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.
- metapathology-0.1.0/PKG-INFO +84 -0
- metapathology-0.1.0/README.md +75 -0
- metapathology-0.1.0/pyproject.toml +87 -0
- metapathology-0.1.0/src/metapathology/__init__.py +36 -0
- metapathology-0.1.0/src/metapathology/__main__.py +92 -0
- metapathology-0.1.0/src/metapathology/_monitor.py +570 -0
- metapathology-0.1.0/src/metapathology/_records.py +134 -0
- metapathology-0.1.0/src/metapathology/_report.py +286 -0
- metapathology-0.1.0/src/metapathology/py.typed +0 -0
|
@@ -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
|