hookfix 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hookfix/__init__.py +15 -0
- hookfix/__main__.py +6 -0
- hookfix/_bootstrap.py +76 -0
- hookfix/cli.py +267 -0
- hookfix/differ.py +66 -0
- hookfix/errors.py +19 -0
- hookfix/model.py +135 -0
- hookfix/py.typed +0 -0
- hookfix/report.py +89 -0
- hookfix/scanner.py +164 -0
- hookfix/spec_writer.py +41 -0
- hookfix/tracer.py +125 -0
- hookfix-0.1.0.dist-info/METADATA +225 -0
- hookfix-0.1.0.dist-info/RECORD +17 -0
- hookfix-0.1.0.dist-info/WHEEL +4 -0
- hookfix-0.1.0.dist-info/entry_points.txt +2 -0
- hookfix-0.1.0.dist-info/licenses/LICENSE +21 -0
hookfix/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""hookfix — find the hidden imports that break your frozen Python app.
|
|
2
|
+
|
|
3
|
+
Freezers such as PyInstaller and Nuitka discover dependencies by reading your
|
|
4
|
+
source code. Imports that are constructed at runtime are invisible to that
|
|
5
|
+
analysis, so the resulting executable crashes with ``ModuleNotFoundError`` on
|
|
6
|
+
the user's machine and not on yours.
|
|
7
|
+
|
|
8
|
+
hookfix runs your program once under a CPython audit hook, records every module
|
|
9
|
+
the interpreter actually imports, and compares that against the static import
|
|
10
|
+
graph. The difference is exactly the set of modules your build is missing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
hookfix/__main__.py
ADDED
hookfix/_bootstrap.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Entry point for the tracer subprocess.
|
|
2
|
+
|
|
3
|
+
Invoked by the CLI as::
|
|
4
|
+
|
|
5
|
+
python -m hookfix._bootstrap <logfile> <script> [args...]
|
|
6
|
+
|
|
7
|
+
It installs the recording meta path finder, writes one ``module<TAB>origin``
|
|
8
|
+
line per import to ``logfile``, then hands control to the user's script with
|
|
9
|
+
:func:`runpy.run_path` so the script sees ``__name__ == "__main__"`` exactly as
|
|
10
|
+
it would under a plain ``python script.py`` invocation.
|
|
11
|
+
|
|
12
|
+
This module is deliberately self-contained: the child process must not import
|
|
13
|
+
the rest of hookfix before the tracer is installed, or those imports would be
|
|
14
|
+
recorded as if the user's program had made them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import runpy
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Recorder:
|
|
26
|
+
"""Minimal meta path finder that logs every resolved module."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, log: Any) -> None:
|
|
29
|
+
self._log = log
|
|
30
|
+
|
|
31
|
+
def find_spec(
|
|
32
|
+
self,
|
|
33
|
+
fullname: str,
|
|
34
|
+
path: Any | None = None,
|
|
35
|
+
target: Any | None = None,
|
|
36
|
+
) -> Any:
|
|
37
|
+
for finder in list(sys.meta_path):
|
|
38
|
+
if finder is self:
|
|
39
|
+
continue
|
|
40
|
+
find_spec = getattr(finder, "find_spec", None)
|
|
41
|
+
if find_spec is None:
|
|
42
|
+
continue
|
|
43
|
+
spec = find_spec(fullname, path, target)
|
|
44
|
+
if spec is not None:
|
|
45
|
+
origin = getattr(spec, "origin", None) or ""
|
|
46
|
+
self._log.write(f"{fullname}\t{origin}\n")
|
|
47
|
+
return spec
|
|
48
|
+
self._log.write(f"{fullname}\t\n")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _main() -> None: # pragma: no cover - exercised via subprocess in tests
|
|
53
|
+
if len(sys.argv) < 3:
|
|
54
|
+
print("usage: python -m hookfix._bootstrap <logfile> <script> [args...]", file=sys.stderr)
|
|
55
|
+
raise SystemExit(2)
|
|
56
|
+
|
|
57
|
+
logfile = sys.argv[1]
|
|
58
|
+
script = sys.argv[2]
|
|
59
|
+
|
|
60
|
+
# Mimic a plain ``python script.py`` invocation: the script's directory
|
|
61
|
+
# becomes the first entry on sys.path, so sibling packages resolve.
|
|
62
|
+
script_dir = os.path.dirname(os.path.abspath(script))
|
|
63
|
+
sys.path.insert(0, script_dir)
|
|
64
|
+
|
|
65
|
+
# Present the script the way a normal ``python script.py`` invocation does.
|
|
66
|
+
sys.argv = [script, *sys.argv[3:]]
|
|
67
|
+
|
|
68
|
+
with open(logfile, "w", encoding="utf-8", buffering=1) as log:
|
|
69
|
+
# Install the recorder last, so nothing hookfix itself imported is
|
|
70
|
+
# logged as if the user's program had imported it.
|
|
71
|
+
sys.meta_path.insert(0, _Recorder(log))
|
|
72
|
+
runpy.run_path(script, run_name="__main__")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__": # pragma: no cover
|
|
76
|
+
_main()
|
hookfix/cli.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Three subcommands, each a step of the same workflow:
|
|
4
|
+
|
|
5
|
+
``hookfix run`` run a script under the tracer and print a report
|
|
6
|
+
``hookfix diff`` compare a saved trace against a static scan
|
|
7
|
+
``hookfix fix`` turn a saved trace into a hook file or spec snippet
|
|
8
|
+
|
|
9
|
+
``run`` is the whole workflow in one command; ``diff`` and ``fix`` exist so the
|
|
10
|
+
trace can be taken once (for example in CI, on the platform that will actually
|
|
11
|
+
be packaged) and inspected later.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
from .differ import diff
|
|
26
|
+
from .errors import HookfixError
|
|
27
|
+
from .model import TraceResult
|
|
28
|
+
from .report import format_report
|
|
29
|
+
from .scanner import scan
|
|
30
|
+
from .spec_writer import render_hook_file, render_spec_patch
|
|
31
|
+
from .tracer import parse_audit_log
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="hookfix",
|
|
37
|
+
description="Find the hidden imports that break your frozen Python app.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--version", action="version", version=f"hookfix {__version__}")
|
|
40
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
|
|
42
|
+
run = subparsers.add_parser(
|
|
43
|
+
"run",
|
|
44
|
+
help="run a script under the tracer and report hidden imports",
|
|
45
|
+
description=(
|
|
46
|
+
"Run a script under a CPython audit hook, record every module it "
|
|
47
|
+
"imports, and compare that against a static scan of the source. "
|
|
48
|
+
"Arguments after '--' are passed through to the traced script."
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
run.add_argument("script", help="path to the entry point to trace")
|
|
52
|
+
run.add_argument(
|
|
53
|
+
"--path",
|
|
54
|
+
default=None,
|
|
55
|
+
help="directory to scan statically (default: the script's directory)",
|
|
56
|
+
)
|
|
57
|
+
run.add_argument(
|
|
58
|
+
"--exclude",
|
|
59
|
+
action="append",
|
|
60
|
+
default=[],
|
|
61
|
+
metavar="DIR",
|
|
62
|
+
help="additional directory name to skip while scanning (repeatable)",
|
|
63
|
+
)
|
|
64
|
+
run.add_argument(
|
|
65
|
+
"--trace-out",
|
|
66
|
+
default=None,
|
|
67
|
+
metavar="FILE",
|
|
68
|
+
help="write the raw runtime trace to FILE as JSON",
|
|
69
|
+
)
|
|
70
|
+
run.add_argument(
|
|
71
|
+
"--json",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="print the diff as JSON instead of a human-readable report",
|
|
74
|
+
)
|
|
75
|
+
run.add_argument(
|
|
76
|
+
"--quiet",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="suppress the traced program's own output",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
diff_cmd = subparsers.add_parser(
|
|
82
|
+
"diff",
|
|
83
|
+
help="compare a saved trace against a static scan",
|
|
84
|
+
description="Read a trace written by 'hookfix run --trace-out' and diff it.",
|
|
85
|
+
)
|
|
86
|
+
diff_cmd.add_argument("trace", help="path to a trace JSON file")
|
|
87
|
+
diff_cmd.add_argument(
|
|
88
|
+
"--path",
|
|
89
|
+
default=None,
|
|
90
|
+
metavar="DIR",
|
|
91
|
+
help="directory to scan statically (default: the trace's directory)",
|
|
92
|
+
)
|
|
93
|
+
diff_cmd.add_argument(
|
|
94
|
+
"--exclude",
|
|
95
|
+
action="append",
|
|
96
|
+
default=[],
|
|
97
|
+
metavar="DIR",
|
|
98
|
+
help="additional directory name to skip while scanning (repeatable)",
|
|
99
|
+
)
|
|
100
|
+
diff_cmd.add_argument("--json", action="store_true", help="print the diff as JSON")
|
|
101
|
+
|
|
102
|
+
fix = subparsers.add_parser(
|
|
103
|
+
"fix",
|
|
104
|
+
help="generate a hook file or spec snippet from a trace",
|
|
105
|
+
description="Turn a saved trace into build configuration.",
|
|
106
|
+
)
|
|
107
|
+
fix.add_argument("trace", help="path to a trace JSON file")
|
|
108
|
+
fix.add_argument(
|
|
109
|
+
"--path",
|
|
110
|
+
default=None,
|
|
111
|
+
metavar="DIR",
|
|
112
|
+
help="directory to scan statically (default: the trace's directory)",
|
|
113
|
+
)
|
|
114
|
+
fix.add_argument(
|
|
115
|
+
"--exclude",
|
|
116
|
+
action="append",
|
|
117
|
+
default=[],
|
|
118
|
+
metavar="DIR",
|
|
119
|
+
help="additional directory name to skip while scanning (repeatable)",
|
|
120
|
+
)
|
|
121
|
+
fix.add_argument(
|
|
122
|
+
"--module",
|
|
123
|
+
default=None,
|
|
124
|
+
metavar="NAME",
|
|
125
|
+
help="name the generated hook file 'hook-NAME.py'",
|
|
126
|
+
)
|
|
127
|
+
fix.add_argument(
|
|
128
|
+
"--spec",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="emit a spec-file snippet instead of a hook file",
|
|
131
|
+
)
|
|
132
|
+
fix.add_argument(
|
|
133
|
+
"-o",
|
|
134
|
+
"--output",
|
|
135
|
+
default=None,
|
|
136
|
+
metavar="FILE",
|
|
137
|
+
help="write to FILE instead of stdout",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return parser
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _trace_script(script: Path, script_args: Sequence[str], quiet: bool) -> TraceResult:
|
|
144
|
+
"""Run ``script`` in a child process under the audit hook and collect the trace."""
|
|
145
|
+
with tempfile.TemporaryDirectory(prefix="hookfix-") as tmp:
|
|
146
|
+
logfile = Path(tmp) / "imports.log"
|
|
147
|
+
cmd: list[str] = [
|
|
148
|
+
sys.executable,
|
|
149
|
+
"-m",
|
|
150
|
+
"hookfix._bootstrap",
|
|
151
|
+
str(logfile),
|
|
152
|
+
str(script),
|
|
153
|
+
*script_args,
|
|
154
|
+
]
|
|
155
|
+
completed = subprocess.run(
|
|
156
|
+
cmd,
|
|
157
|
+
stdout=subprocess.DEVNULL if quiet else None,
|
|
158
|
+
stderr=subprocess.DEVNULL if quiet else None,
|
|
159
|
+
check=False,
|
|
160
|
+
)
|
|
161
|
+
log_text = logfile.read_text(encoding="utf-8") if logfile.exists() else ""
|
|
162
|
+
|
|
163
|
+
modules, origins = parse_audit_log(log_text)
|
|
164
|
+
return TraceResult(
|
|
165
|
+
modules=sorted(modules),
|
|
166
|
+
origins=origins,
|
|
167
|
+
returncode=completed.returncode,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _load_trace(path: str) -> TraceResult:
|
|
172
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
173
|
+
return TraceResult.from_dict(data)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
177
|
+
script = Path(args.script).resolve()
|
|
178
|
+
if not script.exists():
|
|
179
|
+
raise HookfixError(f"script does not exist: {script}")
|
|
180
|
+
|
|
181
|
+
trace = _trace_script(script, args.script_args, args.quiet)
|
|
182
|
+
|
|
183
|
+
scan_root = Path(args.path).resolve() if args.path else script.parent
|
|
184
|
+
static = scan(scan_root, excludes=args.exclude)
|
|
185
|
+
result = diff(trace, static)
|
|
186
|
+
|
|
187
|
+
if args.trace_out:
|
|
188
|
+
Path(args.trace_out).write_text(
|
|
189
|
+
json.dumps(trace.to_dict(), indent=2) + "\n", encoding="utf-8"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if args.json:
|
|
193
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
194
|
+
else:
|
|
195
|
+
print(format_report(trace=trace, static=static, result=result, script=str(script)))
|
|
196
|
+
|
|
197
|
+
# A non-zero exit from the traced program is reported, but the diff itself
|
|
198
|
+
# is still useful, so we only propagate the traced status when there is
|
|
199
|
+
# nothing to report.
|
|
200
|
+
if trace.returncode != 0 and not result.missing:
|
|
201
|
+
return trace.returncode
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cmd_diff(args: argparse.Namespace) -> int:
|
|
206
|
+
trace = _load_trace(args.trace)
|
|
207
|
+
scan_root = args.path or str(Path(args.trace).resolve().parent)
|
|
208
|
+
static = scan(scan_root, excludes=args.exclude)
|
|
209
|
+
result = diff(trace, static)
|
|
210
|
+
|
|
211
|
+
if args.json:
|
|
212
|
+
print(json.dumps(result.to_dict(), indent=2))
|
|
213
|
+
else:
|
|
214
|
+
print(
|
|
215
|
+
format_report(
|
|
216
|
+
trace=trace,
|
|
217
|
+
static=static,
|
|
218
|
+
result=result,
|
|
219
|
+
script="(from trace)",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _cmd_fix(args: argparse.Namespace) -> int:
|
|
226
|
+
trace = _load_trace(args.trace)
|
|
227
|
+
scan_root = args.path or str(Path(args.trace).resolve().parent)
|
|
228
|
+
static = scan(scan_root, excludes=args.exclude)
|
|
229
|
+
result = diff(trace, static)
|
|
230
|
+
|
|
231
|
+
if args.spec:
|
|
232
|
+
rendered = render_spec_patch(result.missing)
|
|
233
|
+
else:
|
|
234
|
+
rendered = render_hook_file(result.missing, module_name=args.module)
|
|
235
|
+
|
|
236
|
+
if args.output:
|
|
237
|
+
Path(args.output).write_text(rendered, encoding="utf-8")
|
|
238
|
+
print(f"wrote {args.output} ({len(result.missing)} hidden imports)")
|
|
239
|
+
else:
|
|
240
|
+
print(rendered, end="")
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
245
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
246
|
+
# Split pass-through arguments before argparse sees them, so the traced
|
|
247
|
+
# script's own flags are not mistaken for hookfix's.
|
|
248
|
+
script_args: list[str] = []
|
|
249
|
+
if "--" in raw:
|
|
250
|
+
split = raw.index("--")
|
|
251
|
+
script_args = raw[split + 1 :]
|
|
252
|
+
raw = raw[:split]
|
|
253
|
+
|
|
254
|
+
parser = _build_parser()
|
|
255
|
+
args = parser.parse_args(raw)
|
|
256
|
+
args.script_args = script_args
|
|
257
|
+
|
|
258
|
+
handlers = {"run": _cmd_run, "diff": _cmd_diff, "fix": _cmd_fix}
|
|
259
|
+
try:
|
|
260
|
+
return handlers[args.command](args)
|
|
261
|
+
except HookfixError as exc:
|
|
262
|
+
print(f"hookfix: error: {exc}", file=sys.stderr)
|
|
263
|
+
return 2
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__": # pragma: no cover
|
|
267
|
+
raise SystemExit(main())
|
hookfix/differ.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Compare a runtime trace against the static import graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .model import DiffResult, StaticResult, TraceResult
|
|
8
|
+
|
|
9
|
+
#: Modules the interpreter itself provides. They are always available in a
|
|
10
|
+
#: frozen build, so a runtime-only standard-library import is not a defect.
|
|
11
|
+
_STDLIB = frozenset(getattr(sys, "stdlib_module_names", ()))
|
|
12
|
+
|
|
13
|
+
#: Import machinery internals that appear in every trace and mean nothing to a
|
|
14
|
+
#: user reading the report.
|
|
15
|
+
_INTERNAL_PREFIXES = ("_", "importlib")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_noise(name: str) -> bool:
|
|
19
|
+
if name in {"builtins", "sys", "os", "io", "abc", "codecs", "site", "types", "warnings"}:
|
|
20
|
+
return False
|
|
21
|
+
return name.startswith(_INTERNAL_PREFIXES) or name.startswith("hookfix")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _stdlib_names() -> set[str]:
|
|
25
|
+
if _STDLIB:
|
|
26
|
+
return set(_STDLIB)
|
|
27
|
+
# Very old interpreters have no ``sys.stdlib_module_names``. Fall back to a
|
|
28
|
+
# conservative empty set: better to over-report than to hide a real gap.
|
|
29
|
+
return set()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def diff(trace: TraceResult, static: StaticResult) -> DiffResult:
|
|
33
|
+
"""Return the runtime imports that the static scan did not find.
|
|
34
|
+
|
|
35
|
+
``missing`` holds fully-qualified module names, because that is what a
|
|
36
|
+
freezer needs: ``--hidden-import=reporters.json_reporter``, not the bare
|
|
37
|
+
package name. ``stdlib_only`` is kept separate because a freezer bundles
|
|
38
|
+
the standard library regardless.
|
|
39
|
+
"""
|
|
40
|
+
static_names = set(static.imports)
|
|
41
|
+
runtime_names = {name for name in trace.modules if not _is_noise(name)}
|
|
42
|
+
stdlib = _stdlib_names()
|
|
43
|
+
|
|
44
|
+
missing: set[str] = set()
|
|
45
|
+
stdlib_only: set[str] = set()
|
|
46
|
+
common: set[str] = set()
|
|
47
|
+
|
|
48
|
+
for name in runtime_names:
|
|
49
|
+
if name in static_names:
|
|
50
|
+
common.add(name)
|
|
51
|
+
elif name.split(".")[0] in stdlib:
|
|
52
|
+
stdlib_only.add(name)
|
|
53
|
+
else:
|
|
54
|
+
missing.add(name)
|
|
55
|
+
|
|
56
|
+
# A package whose own submodule is reported is redundant: importing the
|
|
57
|
+
# submodule pulls the package in, and listing both clutters the config.
|
|
58
|
+
missing = {name for name in missing if not any(
|
|
59
|
+
other != name and other.startswith(name + ".") for other in missing
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
return DiffResult(
|
|
63
|
+
missing=sorted(missing),
|
|
64
|
+
stdlib_only=sorted(stdlib_only),
|
|
65
|
+
common=sorted(common),
|
|
66
|
+
)
|
hookfix/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Exceptions raised by hookfix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HookfixError(Exception):
|
|
7
|
+
"""Base class for every error hookfix raises deliberately.
|
|
8
|
+
|
|
9
|
+
The CLI catches this and prints the message without a traceback, so
|
|
10
|
+
user-facing failures stay readable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TraceError(HookfixError):
|
|
15
|
+
"""The traced program could not be run or its trace could not be collected."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScanError(HookfixError):
|
|
19
|
+
"""The static scan could not complete."""
|
hookfix/model.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Shared data model for hookfix results.
|
|
2
|
+
|
|
3
|
+
The types here are deliberately plain dataclasses with ``to_dict``/``from_dict``
|
|
4
|
+
so that a trace or report can be serialised to JSON, committed as a CI artifact,
|
|
5
|
+
and re-read later on a different machine.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
#: Schema version for the JSON artefacts we write. Bump when the shape changes
|
|
15
|
+
#: in a way that older readers cannot understand.
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DynamicSite:
|
|
21
|
+
"""A call site in the source that imports a module at runtime.
|
|
22
|
+
|
|
23
|
+
These are the places static analysis is blind to. Recording them lets the
|
|
24
|
+
report point at the exact line responsible for a module that only appears
|
|
25
|
+
at runtime.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
path: str
|
|
29
|
+
lineno: int
|
|
30
|
+
col: int
|
|
31
|
+
#: ``importlib.import_module``, ``__import__``, ``exec`` or ``eval``.
|
|
32
|
+
kind: str
|
|
33
|
+
#: The literal argument, when it could be resolved statically.
|
|
34
|
+
target: str | None = None
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict[str, Any]:
|
|
37
|
+
return asdict(self)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, data: dict[str, Any]) -> DynamicSite:
|
|
41
|
+
return cls(**data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class StaticResult:
|
|
46
|
+
"""What a static scan of the source tree can see."""
|
|
47
|
+
|
|
48
|
+
#: Top-level module names referenced by ``import`` statements.
|
|
49
|
+
imports: list[str] = field(default_factory=list)
|
|
50
|
+
#: Locations of dynamic import calls.
|
|
51
|
+
dynamic_sites: list[DynamicSite] = field(default_factory=list)
|
|
52
|
+
#: Files that were scanned.
|
|
53
|
+
files: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"schema_version": SCHEMA_VERSION,
|
|
58
|
+
"imports": self.imports,
|
|
59
|
+
"dynamic_sites": [site.to_dict() for site in self.dynamic_sites],
|
|
60
|
+
"files": self.files,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict[str, Any]) -> StaticResult:
|
|
65
|
+
return cls(
|
|
66
|
+
imports=list(data.get("imports", [])),
|
|
67
|
+
dynamic_sites=[DynamicSite.from_dict(d) for d in data.get("dynamic_sites", [])],
|
|
68
|
+
files=list(data.get("files", [])),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class TraceResult:
|
|
74
|
+
"""What actually happened when the program ran."""
|
|
75
|
+
|
|
76
|
+
#: Every fully-qualified module name the interpreter imported.
|
|
77
|
+
modules: list[str] = field(default_factory=list)
|
|
78
|
+
#: ``name -> file`` for modules that reported a filesystem origin.
|
|
79
|
+
origins: dict[str, str] = field(default_factory=dict)
|
|
80
|
+
#: Interpreter version the trace was taken under.
|
|
81
|
+
python: str = field(default_factory=lambda: sys.version.split()[0])
|
|
82
|
+
#: Exit status of the traced program.
|
|
83
|
+
returncode: int = 0
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def top_level(self) -> list[str]:
|
|
87
|
+
"""Top-level names only (``a.b.c`` collapses to ``a``), sorted."""
|
|
88
|
+
return sorted({name.split(".")[0] for name in self.modules})
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
return {
|
|
92
|
+
"schema_version": SCHEMA_VERSION,
|
|
93
|
+
"python": self.python,
|
|
94
|
+
"returncode": self.returncode,
|
|
95
|
+
"modules": self.modules,
|
|
96
|
+
"origins": self.origins,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, data: dict[str, Any]) -> TraceResult:
|
|
101
|
+
return cls(
|
|
102
|
+
modules=list(data.get("modules", [])),
|
|
103
|
+
origins=dict(data.get("origins", {})),
|
|
104
|
+
python=data.get("python", ""),
|
|
105
|
+
returncode=int(data.get("returncode", 0)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class DiffResult:
|
|
111
|
+
"""Modules imported at runtime that static analysis could not see."""
|
|
112
|
+
|
|
113
|
+
#: Top-level names present at runtime but absent from the static scan.
|
|
114
|
+
missing: list[str] = field(default_factory=list)
|
|
115
|
+
#: Runtime-only names that live in the standard library. Informational:
|
|
116
|
+
#: a freezer normally bundles these already.
|
|
117
|
+
stdlib_only: list[str] = field(default_factory=list)
|
|
118
|
+
#: Names seen by both, for context.
|
|
119
|
+
common: list[str] = field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"schema_version": SCHEMA_VERSION,
|
|
124
|
+
"missing": self.missing,
|
|
125
|
+
"stdlib_only": self.stdlib_only,
|
|
126
|
+
"common": self.common,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_dict(cls, data: dict[str, Any]) -> DiffResult:
|
|
131
|
+
return cls(
|
|
132
|
+
missing=list(data.get("missing", [])),
|
|
133
|
+
stdlib_only=list(data.get("stdlib_only", [])),
|
|
134
|
+
common=list(data.get("common", [])),
|
|
135
|
+
)
|
hookfix/py.typed
ADDED
|
File without changes
|
hookfix/report.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Render results as a human-readable report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .model import DiffResult, StaticResult, TraceResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _plural(count: int, singular: str, plural: str | None = None) -> str:
|
|
9
|
+
word = singular if count == 1 else (plural or singular + "s")
|
|
10
|
+
return f"{count} {word}"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_report(
|
|
14
|
+
*,
|
|
15
|
+
trace: TraceResult,
|
|
16
|
+
static: StaticResult,
|
|
17
|
+
result: DiffResult,
|
|
18
|
+
script: str,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Build the multi-section report printed by ``hookfix run``."""
|
|
21
|
+
lines: list[str] = []
|
|
22
|
+
|
|
23
|
+
lines.append("hookfix report")
|
|
24
|
+
lines.append("=" * 14)
|
|
25
|
+
lines.append("")
|
|
26
|
+
lines.append(f"script {script}")
|
|
27
|
+
lines.append(f"python {trace.python}")
|
|
28
|
+
lines.append(f"exit status {trace.returncode}")
|
|
29
|
+
lines.append(
|
|
30
|
+
f"scanned {_plural(len(static.files), 'file')}, "
|
|
31
|
+
f"{_plural(len(static.imports), 'static import')}"
|
|
32
|
+
)
|
|
33
|
+
lines.append(f"observed {_plural(len(trace.modules), 'module')} imported at runtime")
|
|
34
|
+
lines.append("")
|
|
35
|
+
|
|
36
|
+
if not result.missing:
|
|
37
|
+
lines.append("No hidden imports found.")
|
|
38
|
+
lines.append("")
|
|
39
|
+
lines.append(
|
|
40
|
+
"Every third-party module this run imported is also visible to a "
|
|
41
|
+
"static scan, so the build should already include it."
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
lines.append(f"Hidden imports ({len(result.missing)})")
|
|
45
|
+
lines.append("-" * (16 + len(str(len(result.missing)))))
|
|
46
|
+
for name in result.missing:
|
|
47
|
+
lines.append(f" {name}")
|
|
48
|
+
lines.append("")
|
|
49
|
+
lines.append(
|
|
50
|
+
"These modules were imported at runtime but are invisible to a "
|
|
51
|
+
"static scan. Add them to your build:"
|
|
52
|
+
)
|
|
53
|
+
lines.append("")
|
|
54
|
+
for name in result.missing:
|
|
55
|
+
lines.append(f" pyinstaller --hidden-import={name} ...")
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.append("Or generate a hook file for all of them at once:")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append(" hookfix fix")
|
|
60
|
+
|
|
61
|
+
if result.stdlib_only:
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append(f"Standard library only ({len(result.stdlib_only)})")
|
|
64
|
+
lines.append("-" * (23 + len(str(len(result.stdlib_only)))))
|
|
65
|
+
lines.append(" " + ", ".join(result.stdlib_only))
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append(
|
|
68
|
+
"Imported at runtime, but part of the standard library. A freezer "
|
|
69
|
+
"bundles these already; listed for completeness."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if static.dynamic_sites:
|
|
73
|
+
lines.append("")
|
|
74
|
+
lines.append(f"Dynamic import sites ({len(static.dynamic_sites)})")
|
|
75
|
+
lines.append("-" * (22 + len(str(len(static.dynamic_sites)))))
|
|
76
|
+
for site in static.dynamic_sites[:20]:
|
|
77
|
+
location = f"{site.path}:{site.lineno}:{site.col}"
|
|
78
|
+
target = f" -> {site.target!r}" if site.target else ""
|
|
79
|
+
lines.append(f" {location} {site.kind}{target}")
|
|
80
|
+
if len(static.dynamic_sites) > 20:
|
|
81
|
+
lines.append(f" ... and {len(static.dynamic_sites) - 20} more")
|
|
82
|
+
lines.append("")
|
|
83
|
+
lines.append(
|
|
84
|
+
"These call sites are why the imports above are invisible to "
|
|
85
|
+
"static analysis."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
lines.append("")
|
|
89
|
+
return "\n".join(lines)
|
hookfix/scanner.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Static analysis of a source tree.
|
|
2
|
+
|
|
3
|
+
This module reproduces the part of the problem freezers already solve: reading
|
|
4
|
+
``import`` statements out of source files. hookfix needs it so that it can
|
|
5
|
+
subtract the statically visible imports from the runtime trace and leave only
|
|
6
|
+
the imports the build is actually missing.
|
|
7
|
+
|
|
8
|
+
We parse with :mod:`ast` rather than regular expressions so that imports inside
|
|
9
|
+
functions, conditionals and ``try`` blocks are all found, and so that the
|
|
10
|
+
location of every dynamic import call can be reported precisely.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .errors import ScanError
|
|
21
|
+
from .model import DynamicSite, StaticResult
|
|
22
|
+
|
|
23
|
+
#: Functions that import a module from a string. ``importlib.import_module``
|
|
24
|
+
#: and ``__import__`` are the common ones; ``exec``/``eval`` can execute an
|
|
25
|
+
#: import statement assembled at runtime.
|
|
26
|
+
_DYNAMIC_CALLS = {
|
|
27
|
+
("importlib", "import_module"),
|
|
28
|
+
("__import__", None),
|
|
29
|
+
("builtins", "__import__"),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_EXEC_CALLS = {"exec", "eval"}
|
|
33
|
+
|
|
34
|
+
_DEFAULT_EXCLUDES = {
|
|
35
|
+
".git",
|
|
36
|
+
".hg",
|
|
37
|
+
".svn",
|
|
38
|
+
".venv",
|
|
39
|
+
"venv",
|
|
40
|
+
"env",
|
|
41
|
+
"build",
|
|
42
|
+
"dist",
|
|
43
|
+
"__pycache__",
|
|
44
|
+
"node_modules",
|
|
45
|
+
".tox",
|
|
46
|
+
".nox",
|
|
47
|
+
".mypy_cache",
|
|
48
|
+
".ruff_cache",
|
|
49
|
+
".pytest_cache",
|
|
50
|
+
".hookfix",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _iter_python_files(root: Path, excludes: set[str]) -> Iterable[Path]:
|
|
55
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
56
|
+
dirnames[:] = sorted(d for d in dirnames if d not in excludes and not d.startswith("."))
|
|
57
|
+
for filename in sorted(filenames):
|
|
58
|
+
if filename.endswith(".py"):
|
|
59
|
+
yield Path(dirpath) / filename
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _call_name(node: ast.Call) -> list[str]:
|
|
63
|
+
"""Return the dotted components of a call target, e.g. ``["importlib", "import_module"]``."""
|
|
64
|
+
func = node.func
|
|
65
|
+
parts: list[str] = []
|
|
66
|
+
while isinstance(func, ast.Attribute):
|
|
67
|
+
parts.append(func.attr)
|
|
68
|
+
func = func.value
|
|
69
|
+
if isinstance(func, ast.Name):
|
|
70
|
+
parts.append(func.id)
|
|
71
|
+
return list(reversed(parts))
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _literal_arg(node: ast.Call) -> str | None:
|
|
76
|
+
if not node.args:
|
|
77
|
+
return None
|
|
78
|
+
first = node.args[0]
|
|
79
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
80
|
+
return first.value
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _scan_tree(tree: ast.AST, relpath: str) -> tuple[set[str], list[DynamicSite]]:
|
|
85
|
+
imports: set[str] = set()
|
|
86
|
+
sites: list[DynamicSite] = []
|
|
87
|
+
|
|
88
|
+
for node in ast.walk(tree):
|
|
89
|
+
if isinstance(node, ast.Import):
|
|
90
|
+
for alias in node.names:
|
|
91
|
+
# Keep the fully-qualified name: ``import a.b`` tells a freezer
|
|
92
|
+
# about ``a.b``, and the differ compares full names.
|
|
93
|
+
imports.add(alias.name)
|
|
94
|
+
elif isinstance(node, ast.ImportFrom):
|
|
95
|
+
# ``from . import x`` has module=None and level>0; that is a
|
|
96
|
+
# relative import and never a missing third-party dependency.
|
|
97
|
+
if node.level == 0 and node.module:
|
|
98
|
+
imports.add(node.module)
|
|
99
|
+
for alias in node.names:
|
|
100
|
+
if alias.name != "*":
|
|
101
|
+
imports.add(f"{node.module}.{alias.name}")
|
|
102
|
+
elif isinstance(node, ast.Call):
|
|
103
|
+
parts = _call_name(node)
|
|
104
|
+
if not parts:
|
|
105
|
+
continue
|
|
106
|
+
dotted = tuple(parts[-2:]) if len(parts) >= 2 else (parts[0], None)
|
|
107
|
+
simple = parts[-1]
|
|
108
|
+
if dotted in _DYNAMIC_CALLS or (simple == "__import__" and len(parts) == 1):
|
|
109
|
+
sites.append(
|
|
110
|
+
DynamicSite(
|
|
111
|
+
path=relpath,
|
|
112
|
+
lineno=node.lineno,
|
|
113
|
+
col=node.col_offset,
|
|
114
|
+
kind=".".join(parts),
|
|
115
|
+
target=_literal_arg(node),
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
elif simple in _EXEC_CALLS:
|
|
119
|
+
sites.append(
|
|
120
|
+
DynamicSite(
|
|
121
|
+
path=relpath,
|
|
122
|
+
lineno=node.lineno,
|
|
123
|
+
col=node.col_offset,
|
|
124
|
+
kind=simple,
|
|
125
|
+
target=_literal_arg(node),
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return imports, sites
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def scan(root: os.PathLike[str] | str, excludes: Iterable[str] = ()) -> StaticResult:
|
|
133
|
+
"""Scan ``root`` for import statements and dynamic import call sites."""
|
|
134
|
+
root_path = Path(root).resolve()
|
|
135
|
+
if not root_path.exists():
|
|
136
|
+
raise ScanError(f"path does not exist: {root_path}")
|
|
137
|
+
|
|
138
|
+
exclude_set = set(_DEFAULT_EXCLUDES) | set(excludes)
|
|
139
|
+
all_imports: set[str] = set()
|
|
140
|
+
all_sites: list[DynamicSite] = []
|
|
141
|
+
scanned: list[str] = []
|
|
142
|
+
|
|
143
|
+
for path in _iter_python_files(root_path, exclude_set):
|
|
144
|
+
relpath = str(path.relative_to(root_path))
|
|
145
|
+
try:
|
|
146
|
+
source = path.read_text(encoding="utf-8")
|
|
147
|
+
except (OSError, UnicodeDecodeError):
|
|
148
|
+
continue
|
|
149
|
+
try:
|
|
150
|
+
tree = ast.parse(source, filename=str(path))
|
|
151
|
+
except SyntaxError:
|
|
152
|
+
# A file we cannot parse is not a reason to fail the whole scan;
|
|
153
|
+
# a freezer would report it separately.
|
|
154
|
+
continue
|
|
155
|
+
imports, sites = _scan_tree(tree, relpath)
|
|
156
|
+
all_imports |= imports
|
|
157
|
+
all_sites.extend(sites)
|
|
158
|
+
scanned.append(relpath)
|
|
159
|
+
|
|
160
|
+
return StaticResult(
|
|
161
|
+
imports=sorted(all_imports),
|
|
162
|
+
dynamic_sites=all_sites,
|
|
163
|
+
files=scanned,
|
|
164
|
+
)
|
hookfix/spec_writer.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Turn a diff result into something a freezer understands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
_HEADER = (
|
|
8
|
+
"# Generated by hookfix. https://github.com/sqmyou/hookfix\n"
|
|
9
|
+
"#\n"
|
|
10
|
+
"# These modules were imported at runtime by the traced program but are not\n"
|
|
11
|
+
"# reachable from a static scan of the source, so PyInstaller would omit them.\n"
|
|
12
|
+
"# Review the list before committing: it reflects one execution of the program.\n"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_hook_file(modules: Sequence[str], *, module_name: str | None = None) -> str:
|
|
17
|
+
"""Render a PyInstaller hook file (``hook-<name>.py``).
|
|
18
|
+
|
|
19
|
+
A hook is the reusable form of ``--hidden-import``: it applies automatically
|
|
20
|
+
whenever the named module is imported, including inside a frozen build.
|
|
21
|
+
"""
|
|
22
|
+
title = module_name or "your_app"
|
|
23
|
+
body = "\n".join(f" {name!r}," for name in modules) or " # (none)"
|
|
24
|
+
return (
|
|
25
|
+
f"{_HEADER}"
|
|
26
|
+
f"# hook-{title}.py\n"
|
|
27
|
+
f"\n"
|
|
28
|
+
f"hiddenimports = [\n"
|
|
29
|
+
f"{body}\n"
|
|
30
|
+
f"]\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_spec_patch(modules: Sequence[str]) -> str:
|
|
35
|
+
"""Render the ``hiddenimports`` line to paste into an existing ``.spec`` file.
|
|
36
|
+
|
|
37
|
+
PyInstaller's generated spec files already contain a ``hiddenimports = []``
|
|
38
|
+
entry; this is the drop-in replacement for it.
|
|
39
|
+
"""
|
|
40
|
+
body = "\n".join(f" {name!r}," for name in modules) or " # (none)"
|
|
41
|
+
return f"hiddenimports = [\n{body}\n]\n"
|
hookfix/tracer.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Runtime import tracer.
|
|
2
|
+
|
|
3
|
+
The tracer works by installing a :class:`importlib.abc.MetaPathFinder` at the
|
|
4
|
+
front of :data:`sys.meta_path`. Every module resolution that goes through the
|
|
5
|
+
import system passes through the finders in ``sys.meta_path``, so a finder
|
|
6
|
+
placed at the front observes imports that originate from an ``import``
|
|
7
|
+
statement, from :func:`importlib.import_module`, from :func:`__import__`, and
|
|
8
|
+
from lazy ``__getattr__`` loaders alike.
|
|
9
|
+
|
|
10
|
+
Why not CPython audit hooks? The ``import`` audit event is raised only on the
|
|
11
|
+
``import`` statement / ``__import__`` path. :func:`importlib.import_module`
|
|
12
|
+
calls the internal ``_gcd_import`` directly and never raises it, which is
|
|
13
|
+
precisely the dynamic case that breaks frozen builds. A meta path finder has no
|
|
14
|
+
such blind spot.
|
|
15
|
+
|
|
16
|
+
The finder records each module name the first time it is resolved, together
|
|
17
|
+
with the file it came from. Modules already present in :data:`sys.modules`
|
|
18
|
+
before the tracer is installed are not seen, so the tracer must be installed
|
|
19
|
+
before the program under test runs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .errors import TraceError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ImportTracer:
|
|
32
|
+
"""Record module imports performed by the running interpreter."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.modules: set[str] = set()
|
|
36
|
+
self.origins: dict[str, str] = {}
|
|
37
|
+
self._installed = False
|
|
38
|
+
|
|
39
|
+
def install(self) -> None:
|
|
40
|
+
"""Insert the recording finder at the front of ``sys.meta_path``."""
|
|
41
|
+
if self._installed:
|
|
42
|
+
raise TraceError("ImportTracer.install() may only be called once")
|
|
43
|
+
if not hasattr(sys, "meta_path"):
|
|
44
|
+
raise TraceError("this interpreter does not provide sys.meta_path")
|
|
45
|
+
sys.meta_path.insert(0, _RecordingFinder(self))
|
|
46
|
+
self._installed = True
|
|
47
|
+
|
|
48
|
+
def record(self, name: str, origin: str | None) -> None:
|
|
49
|
+
self.modules.add(name)
|
|
50
|
+
if origin and name not in self.origins:
|
|
51
|
+
self.origins[name] = origin
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _RecordingFinder:
|
|
55
|
+
"""A meta path finder that records names and delegates to the real finders.
|
|
56
|
+
|
|
57
|
+
It must reproduce the search the interpreter would otherwise perform, so it
|
|
58
|
+
walks the remaining ``sys.meta_path`` entries in order and returns the first
|
|
59
|
+
spec any of them produces. Returning a spec of our own, or delegating to a
|
|
60
|
+
single finder, would break built-in and frozen modules.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, tracer: ImportTracer) -> None:
|
|
64
|
+
self._tracer = tracer
|
|
65
|
+
|
|
66
|
+
def find_spec(
|
|
67
|
+
self,
|
|
68
|
+
fullname: str,
|
|
69
|
+
path: Any | None = None,
|
|
70
|
+
target: Any | None = None,
|
|
71
|
+
) -> Any:
|
|
72
|
+
for finder in list(sys.meta_path):
|
|
73
|
+
if finder is self:
|
|
74
|
+
continue
|
|
75
|
+
find_spec = getattr(finder, "find_spec", None)
|
|
76
|
+
if find_spec is None:
|
|
77
|
+
continue
|
|
78
|
+
spec = find_spec(fullname, path, target)
|
|
79
|
+
if spec is not None:
|
|
80
|
+
self._tracer.record(fullname, getattr(spec, "origin", None))
|
|
81
|
+
return spec
|
|
82
|
+
# Nothing could resolve it. Record the attempt so that a name which is
|
|
83
|
+
# imported dynamically but absent from the environment is still visible.
|
|
84
|
+
self._tracer.record(fullname, None)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def traced_run(target: Callable[..., int], *args: Any, **kwargs: Any) -> tuple[ImportTracer, int]:
|
|
89
|
+
"""Run ``target`` under a tracer and return ``(tracer, returncode)``."""
|
|
90
|
+
tracer = ImportTracer()
|
|
91
|
+
tracer.install()
|
|
92
|
+
try:
|
|
93
|
+
returncode = target(*args, **kwargs) or 0
|
|
94
|
+
except SystemExit as exc: # a program is allowed to call sys.exit()
|
|
95
|
+
code = exc.code
|
|
96
|
+
if code is None:
|
|
97
|
+
returncode = 0
|
|
98
|
+
elif isinstance(code, int):
|
|
99
|
+
returncode = code
|
|
100
|
+
else:
|
|
101
|
+
print(code, file=sys.stderr)
|
|
102
|
+
returncode = 1
|
|
103
|
+
return tracer, returncode
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_audit_log(text: str) -> tuple[set[str], dict[str, str]]:
|
|
107
|
+
"""Parse the line-oriented log written by the subprocess bootstrap.
|
|
108
|
+
|
|
109
|
+
Each line is ``module<TAB>origin`` with an empty origin when the import
|
|
110
|
+
machinery did not report a file. This keeps the child process free of any
|
|
111
|
+
dependency on the rest of hookfix.
|
|
112
|
+
"""
|
|
113
|
+
modules: set[str] = set()
|
|
114
|
+
origins: dict[str, str] = {}
|
|
115
|
+
for line in text.splitlines():
|
|
116
|
+
if not line.strip():
|
|
117
|
+
continue
|
|
118
|
+
name, _, origin = line.partition("\t")
|
|
119
|
+
name = name.strip()
|
|
120
|
+
if not name:
|
|
121
|
+
continue
|
|
122
|
+
modules.add(name)
|
|
123
|
+
if origin and name not in origins:
|
|
124
|
+
origins[name] = origin
|
|
125
|
+
return modules, origins
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hookfix
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find the hidden imports that break your frozen Python app.
|
|
5
|
+
Project-URL: Homepage, https://github.com/sqmyou/hookfix
|
|
6
|
+
Project-URL: Repository, https://github.com/sqmyou/hookfix
|
|
7
|
+
Project-URL: Changelog, https://github.com/sqmyou/hookfix/blob/main/CHANGELOG.md
|
|
8
|
+
Author-email: sqm <sqmyou@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bundling,freeze,hidden-imports,nuitka,packaging,pyinstaller
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# hookfix
|
|
34
|
+
|
|
35
|
+
**Find the hidden imports that break your frozen Python app.**
|
|
36
|
+
|
|
37
|
+
[](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/hookfix/)
|
|
39
|
+
[](https://pypi.org/project/hookfix/)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
You build your app, it works perfectly. You freeze it with PyInstaller or
|
|
45
|
+
Nuitka, ship the binary, and it dies on a user's machine with:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
ModuleNotFoundError: No module named 'your_plugin'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The module is installed. It is in your source tree. It just never appears in an
|
|
52
|
+
`import` statement that a static analyser can see — it is pulled in by
|
|
53
|
+
`importlib.import_module`, a plugin registry, an entry point, or a
|
|
54
|
+
`__getattr__` on a package. Freezers trace imports by reading source, so they
|
|
55
|
+
miss it, and you get to add `--hidden-import` flags one error report at a time.
|
|
56
|
+
|
|
57
|
+
`hookfix` takes the other route. It **runs** your program, watches every module
|
|
58
|
+
the interpreter actually imports, subtracts the imports that are visible to a
|
|
59
|
+
static scan, and hands you the difference — the hidden imports, ready to paste
|
|
60
|
+
into your build.
|
|
61
|
+
|
|
62
|
+
```console
|
|
63
|
+
$ hookfix run app.py --path .
|
|
64
|
+
|
|
65
|
+
hookfix report
|
|
66
|
+
==============
|
|
67
|
+
|
|
68
|
+
script /home/you/app/app.py
|
|
69
|
+
python 3.12.4
|
|
70
|
+
exit status 0
|
|
71
|
+
scanned 12 files, 34 static imports
|
|
72
|
+
observed 87 modules imported at runtime
|
|
73
|
+
|
|
74
|
+
Hidden imports (2)
|
|
75
|
+
------------------
|
|
76
|
+
plugins.report
|
|
77
|
+
yaml
|
|
78
|
+
|
|
79
|
+
These modules were imported at runtime but are invisible to a static scan.
|
|
80
|
+
Add them to your build:
|
|
81
|
+
|
|
82
|
+
pyinstaller --hidden-import=plugins.report --hidden-import=yaml ...
|
|
83
|
+
|
|
84
|
+
Or generate a hook file for all of them at once:
|
|
85
|
+
|
|
86
|
+
hookfix fix
|
|
87
|
+
|
|
88
|
+
Dynamic import sites (2)
|
|
89
|
+
------------------------
|
|
90
|
+
app.py:41:12 importlib.import_module
|
|
91
|
+
plugins/load.py:9:5 __import__
|
|
92
|
+
|
|
93
|
+
These call sites are why the imports above are invisible to static analysis.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Why not just use audit hooks?
|
|
97
|
+
|
|
98
|
+
The obvious way to watch imports is a CPython audit hook
|
|
99
|
+
([PEP 578](https://peps.python.org/pep-0578/)) listening for the `import`
|
|
100
|
+
event. It does not work, and the reason is subtle enough to be worth stating:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
import importlib
|
|
104
|
+
importlib.import_module("plugins.report") # does NOT raise the import event
|
|
105
|
+
__import__("plugins.report") # raises it
|
|
106
|
+
import plugins.report # raises it
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`importlib.import_module` calls the internal `_gcd_import` directly and never
|
|
110
|
+
raises the audit event. Since `importlib.import_module` is *the* standard way to
|
|
111
|
+
load a module dynamically, an audit-hook-based tracer has a blind spot over
|
|
112
|
+
precisely the case it is meant to catch.
|
|
113
|
+
|
|
114
|
+
`hookfix` installs an `importlib.abc.MetaPathFinder` at the front of
|
|
115
|
+
`sys.meta_path` instead. Every module resolution that goes through the import
|
|
116
|
+
system passes through `sys.meta_path`, whatever triggered it — so statements,
|
|
117
|
+
`importlib`, `__import__` and lazy loaders are all observed.
|
|
118
|
+
|
|
119
|
+
## Installation
|
|
120
|
+
|
|
121
|
+
```console
|
|
122
|
+
pip install hookfix
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`hookfix` has no runtime dependencies and needs Python 3.10 or newer.
|
|
126
|
+
|
|
127
|
+
## Usage
|
|
128
|
+
|
|
129
|
+
### `hookfix run` — trace a program
|
|
130
|
+
|
|
131
|
+
```console
|
|
132
|
+
hookfix run app.py --path .
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Runs `app.py` under the tracer and prints the report above. `--path` sets the
|
|
136
|
+
directory to scan statically (default: the script's directory). Arguments after
|
|
137
|
+
`--` are passed through to your program:
|
|
138
|
+
|
|
139
|
+
```console
|
|
140
|
+
hookfix run app.py -- --config prod.yaml
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Useful flags:
|
|
144
|
+
|
|
145
|
+
| Flag | Meaning |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `--path DIR` | directory to scan statically (default: the script's directory) |
|
|
148
|
+
| `--exclude NAME` | skip a directory or module while scanning (repeatable) |
|
|
149
|
+
| `--trace-out FILE` | save the trace as JSON for later use |
|
|
150
|
+
| `--json` | print the report as JSON instead of text |
|
|
151
|
+
| `--quiet` | suppress the traced program's own output |
|
|
152
|
+
|
|
153
|
+
### `hookfix diff` — compare a saved trace against source
|
|
154
|
+
|
|
155
|
+
```console
|
|
156
|
+
hookfix run app.py --trace-out trace.json --quiet
|
|
157
|
+
hookfix diff trace.json --path .
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Re-runs the comparison without executing the program again. Handy in CI, where
|
|
161
|
+
you want to trace once and check the result from a different step.
|
|
162
|
+
|
|
163
|
+
### `hookfix fix` — generate build configuration
|
|
164
|
+
|
|
165
|
+
```console
|
|
166
|
+
hookfix fix trace.json --module app # -> hook-app.py
|
|
167
|
+
hookfix fix trace.json --module app -o out/ # -> out/hook-app.py
|
|
168
|
+
hookfix fix trace.json --spec # -> hiddenimports = [...] snippet
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`--module app` writes a PyInstaller hook file, the reusable form of
|
|
172
|
+
`--hidden-import`. `--spec` prints just the `hiddenimports = [...]` list to drop
|
|
173
|
+
into an existing `.spec` file.
|
|
174
|
+
|
|
175
|
+
## What it does and does not do
|
|
176
|
+
|
|
177
|
+
**It reports what one run actually imported.** That is the honest boundary of
|
|
178
|
+
any runtime tool. If a code path never executed — a plugin for a mode you did
|
|
179
|
+
not exercise, a platform-specific branch — its imports will not appear.
|
|
180
|
+
|
|
181
|
+
So: run `hookfix` against the widest set of inputs you can, ideally the same
|
|
182
|
+
ones your smoke tests use. The output tells you which call sites are dynamic, so
|
|
183
|
+
you can see what you might have missed. Treat the generated hook file as a
|
|
184
|
+
starting point to review, not as a finished artefact — the header says as much.
|
|
185
|
+
|
|
186
|
+
It also cannot tell you about data files, native libraries, or metadata that a
|
|
187
|
+
freezer might drop. It is specifically about imports.
|
|
188
|
+
|
|
189
|
+
## How it works
|
|
190
|
+
|
|
191
|
+
1. **Trace.** The CLI spawns a child process (`python -m hookfix._bootstrap`)
|
|
192
|
+
that installs a recording meta path finder and then runs your script with
|
|
193
|
+
`runpy`, mimicking a plain `python script.py` invocation. Every resolved
|
|
194
|
+
module is logged as `name<TAB>origin`.
|
|
195
|
+
|
|
196
|
+
2. **Scan.** `hookfix` walks the source tree with `ast` and collects every
|
|
197
|
+
`import` statement, plus the location of every dynamic import call site.
|
|
198
|
+
|
|
199
|
+
3. **Diff.** The runtime modules minus the statically visible ones are the
|
|
200
|
+
hidden imports. Standard-library modules are split out (a freezer bundles
|
|
201
|
+
those anyway) and import-machinery internals are filtered as noise.
|
|
202
|
+
|
|
203
|
+
4. **Report.** The remainder is printed, or rendered as a hook file or spec
|
|
204
|
+
snippet.
|
|
205
|
+
|
|
206
|
+
Everything is serialisable: `--trace-out` writes a versioned JSON document, and
|
|
207
|
+
`diff`/`fix` read it back, so a trace taken on one machine can be inspected on
|
|
208
|
+
another.
|
|
209
|
+
|
|
210
|
+
## Development
|
|
211
|
+
|
|
212
|
+
```console
|
|
213
|
+
git clone https://github.com/sqmyou/hookfix
|
|
214
|
+
cd hookfix
|
|
215
|
+
python -m pip install -e ".[dev]"
|
|
216
|
+
python -m pytest
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The test suite includes a fixture (`tests/fixtures/dynamic_app`) that loads a
|
|
220
|
+
plugin through `importlib.import_module` — the case that motivated the whole
|
|
221
|
+
tool. `python -m ruff check src tests` and `python -m mypy` must both pass.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
hookfix/__init__.py,sha256=g7shgfLSFdnAAblS0OHfZEPI0GaWe8YgpLm-99cxxPQ,631
|
|
2
|
+
hookfix/__main__.py,sha256=1I0yUuTSpAXxOzfwFAL0pP_F_PnB_bJgjuTy66SUB9E,115
|
|
3
|
+
hookfix/_bootstrap.py,sha256=9gSQDcEKbd-UkCJlQXIfcaQtJYRC2Lu1njZDpSk8Lgg,2541
|
|
4
|
+
hookfix/cli.py,sha256=9-vFfpnGfPOlg0N_63XAHCPRJ6zXEQ4SdIbDu4ki2Hc,8398
|
|
5
|
+
hookfix/differ.py,sha256=pjy7UG8nk0HO_94JrNyFHaWXQDxZFb4tPPkxWe_Gr3k,2299
|
|
6
|
+
hookfix/errors.py,sha256=tgxIrPl-Ei32z3HtBMSsMjy7c_V8ng3jlh4Ihu1Y2eY,485
|
|
7
|
+
hookfix/model.py,sha256=X7ua-q_NVHXzxL9o7l1DeFEEqvfLSKdbqo6nMF_4Uk8,4481
|
|
8
|
+
hookfix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
hookfix/report.py,sha256=XXfrzYT8oj3KWEMv6U1NC-18kw4hZ1sN3BI9doOsjto,3233
|
|
10
|
+
hookfix/scanner.py,sha256=_SapnnfsjHagoQhN5aKAYVwaWwMWwEOvJYSAA3xyO6c,5476
|
|
11
|
+
hookfix/spec_writer.py,sha256=MVuxxoIOG-xNlFiHE5OojX003zHnDdc3kPwbNCoh7tw,1467
|
|
12
|
+
hookfix/tracer.py,sha256=6Q9SYmotUC0W_g_3sBnDKBAnf7UFt_W0IrT9D4-9Or4,4651
|
|
13
|
+
hookfix-0.1.0.dist-info/METADATA,sha256=bs-XIdbNouAbYvYlQTbwSIqB6wvDGSSykf4OoO_0RUA,8176
|
|
14
|
+
hookfix-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
hookfix-0.1.0.dist-info/entry_points.txt,sha256=vSzIogL00l8T4sP5iRP2loWuCo419JulLgZ5MGwEGz0,45
|
|
16
|
+
hookfix-0.1.0.dist-info/licenses/LICENSE,sha256=3dyz4DxrJIHePfCQ--8M4S9LtLEO9d3C7tV5a4ebd1Y,1060
|
|
17
|
+
hookfix-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sqm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|