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 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
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m hookfix``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
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
+ [![CI](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml/badge.svg)](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/hookfix.svg)](https://pypi.org/project/hookfix/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/hookfix.svg)](https://pypi.org/project/hookfix/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hookfix = hookfix.cli:main
@@ -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.