hookfix 0.1.1__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hookfix-0.1.1 → hookfix-0.1.2}/AGENTS.md +27 -2
- hookfix-0.1.2/CHANGELOG.md +141 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/DEMO.md +28 -8
- {hookfix-0.1.1 → hookfix-0.1.2}/PKG-INFO +75 -12
- {hookfix-0.1.1 → hookfix-0.1.2}/README.md +73 -11
- {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/main.py +6 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/pyproject.toml +6 -1
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/__init__.py +3 -2
- hookfix-0.1.2/src/hookfix/_bootstrap.py +121 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/cli.py +148 -14
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/differ.py +44 -12
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/model.py +5 -0
- hookfix-0.1.2/src/hookfix/report.py +164 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/spec_writer.py +23 -6
- hookfix-0.1.2/tests/conftest.py +34 -0
- hookfix-0.1.2/tests/fixtures/hooked_app/app.py +13 -0
- hookfix-0.1.2/tests/fixtures/hooked_app/dynpkg/__init__.py +1 -0
- hookfix-0.1.2/tests/fixtures/nested_app/dynpkg/helper.py +1 -0
- hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/__init__.py +0 -0
- hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/b/__init__.py +0 -0
- hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/b/__main__.py +3 -0
- hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/b/worker.py +1 -0
- hookfix-0.1.2/tests/fixtures/pkg_app/mypkg/__init__.py +1 -0
- hookfix-0.1.2/tests/fixtures/pkg_app/mypkg/__main__.py +6 -0
- hookfix-0.1.2/tests/fixtures/pkg_app/mypkg/worker.py +1 -0
- hookfix-0.1.2/tests/fixtures/private_app/_priv/__init__.py +1 -0
- hookfix-0.1.2/tests/fixtures/private_app/_priv/_core.py +1 -0
- hookfix-0.1.2/tests/fixtures/private_app/app.py +7 -0
- hookfix-0.1.2/tests/fixtures/two_pkgs_app/alpha/__init__.py +1 -0
- hookfix-0.1.2/tests/fixtures/two_pkgs_app/alpha/one.py +1 -0
- hookfix-0.1.2/tests/fixtures/two_pkgs_app/app.py +14 -0
- hookfix-0.1.2/tests/fixtures/two_pkgs_app/beta/__init__.py +1 -0
- hookfix-0.1.2/tests/fixtures/two_pkgs_app/beta/two.py +1 -0
- hookfix-0.1.2/tests/test_cli.py +461 -0
- hookfix-0.1.2/tests/test_differ.py +181 -0
- hookfix-0.1.2/tests/test_freeze_integration.py +154 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/test_spec_writer.py +15 -7
- hookfix-0.1.1/CHANGELOG.md +0 -54
- hookfix-0.1.1/src/hookfix/_bootstrap.py +0 -76
- hookfix-0.1.1/src/hookfix/report.py +0 -89
- hookfix-0.1.1/tests/conftest.py +0 -14
- hookfix-0.1.1/tests/test_cli.py +0 -195
- hookfix-0.1.1/tests/test_differ.py +0 -83
- {hookfix-0.1.1 → hookfix-0.1.2}/.github/workflows/ci.yml +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/.github/workflows/publish.yml +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/.gitignore +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/LICENSE +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/reporters/__init__.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/reporters/json_reporter.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/reporters/text_reporter.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/__main__.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/errors.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/py.typed +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/scanner.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/tracer.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/__init__.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/app.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/plugins/__init__.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/plugins/archive.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/plugins/report.py +0 -0
- {hookfix-0.1.1/tests/fixtures/nested_app → hookfix-0.1.2/tests/fixtures/hooked_app}/dynpkg/helper.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/nested_app/app.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/nested_app/dynpkg/__init__.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/test_scanner.py +0 -0
- {hookfix-0.1.1 → hookfix-0.1.2}/tests/test_tracer.py +0 -0
|
@@ -6,13 +6,15 @@ Repository notes for AI agents and contributors working on hookfix.
|
|
|
6
6
|
|
|
7
7
|
`hookfix` finds the imports that break frozen Python apps. It runs a program
|
|
8
8
|
under a runtime import tracer, subtracts the imports a static scan can see, and
|
|
9
|
-
emits PyInstaller
|
|
9
|
+
emits PyInstaller configuration for the difference. The reported list is
|
|
10
|
+
freezer-agnostic; only `fix`'s output format is PyInstaller-specific (hook files
|
|
11
|
+
and `.spec` snippets). There is no Nuitka config writer.
|
|
10
12
|
|
|
11
13
|
## Commands
|
|
12
14
|
|
|
13
15
|
```console
|
|
14
16
|
python -m pip install -e ".[dev]" # install with test/lint tools
|
|
15
|
-
python -m pytest # test suite
|
|
17
|
+
python -m pytest # test suite
|
|
16
18
|
python -m ruff check src tests # lint
|
|
17
19
|
python -m mypy # strict type check (src/hookfix only)
|
|
18
20
|
```
|
|
@@ -67,6 +69,29 @@ Linux (3.10-3.13) plus macOS and Windows (3.12).
|
|
|
67
69
|
fixed in 0.1.1 — hookfix's own advice produced a binary that crashed. `diff`
|
|
68
70
|
raises rather than fall back to `imports`.
|
|
69
71
|
|
|
72
|
+
6. **A hook file only fires for a module PyInstaller processes.** PyInstaller
|
|
73
|
+
reads `hook-NAME.py` while processing a module called `NAME`. The entry
|
|
74
|
+
script is never imported as a module — PyInstaller knows it as `__main__` —
|
|
75
|
+
so a hook named after the script file is silently dead. Key hooks to the
|
|
76
|
+
top-level package that owns the hidden imports (`hook-reporters.py`). If
|
|
77
|
+
nothing imports that package, no hook can cover its submodules: emit
|
|
78
|
+
`--hidden-import` instead. `hook_targets()` and `_cmd_fix` enforce this.
|
|
79
|
+
|
|
80
|
+
7. **A name with no origin is not a hidden import.** The tracer records an
|
|
81
|
+
import it attempted but could not resolve with an empty origin. There is no
|
|
82
|
+
file to bundle, so `--hidden-import` cannot help; `diff` reports these under
|
|
83
|
+
`unresolved`. Namespace packages resolve without a file, so the tracer logs
|
|
84
|
+
their search location instead of leaving the origin empty.
|
|
85
|
+
|
|
86
|
+
8. **Keep PyInstaller in the dev extras.** The freeze integration test is
|
|
87
|
+
skipped without it, and a skipped test is how the dead hook file shipped in
|
|
88
|
+
the first place. CI installs `.[dev]` and must actually run the freeze.
|
|
89
|
+
|
|
90
|
+
9. **A leading underscore is not noise.** `_mylib._core` is a real private
|
|
91
|
+
package. Filter only `importlib`, hookfix's own modules, and stdlib/built-in
|
|
92
|
+
names — the latter by name, not by prefix, since `_csv` and `_sre` are
|
|
93
|
+
stdlib but `_priv` is not. A prefix rule here silently hides real gaps.
|
|
94
|
+
|
|
70
95
|
## Releasing
|
|
71
96
|
|
|
72
97
|
`main` is the release branch. To cut a release:
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.2] - 2026-10-07
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Packages sharing a filtered prefix were silently dropped.** The noise filter
|
|
12
|
+
used `startswith("importlib")` and `startswith("hookfix")`, so
|
|
13
|
+
`importlib_resources` — a real, widely used package — and any `hookfix_*` name
|
|
14
|
+
vanished from the report. The user saw a clean result and shipped a binary
|
|
15
|
+
that crashed. The filter now matches the exact top-level name, so
|
|
16
|
+
`importlib._bootstrap` is still noise but `importlib_resources` is not.
|
|
17
|
+
- **Private packages were silently dropped.** The noise filter excluded every
|
|
18
|
+
module whose name started with `_`, so a legitimate package like `_mylib`
|
|
19
|
+
whose `_mylib._core` was loaded at runtime never appeared in the report and
|
|
20
|
+
the frozen app died with `ModuleNotFoundError`. A leading underscore is part
|
|
21
|
+
of a name, not a sign of import-machinery noise; only `importlib`, hookfix's
|
|
22
|
+
own modules, and standard-library/built-in names are filtered now. `_csv`,
|
|
23
|
+
`_sre` and friends are still excluded, by name rather than by prefix.
|
|
24
|
+
- **Nested package entry points resolved to the wrong module.** For an app run
|
|
25
|
+
as `python -m a.b`, the tracer returned only the leaf name `b` and put the
|
|
26
|
+
package directory itself on `sys.path`, so the program could not import its
|
|
27
|
+
own package (`ModuleNotFoundError: No module named 'a'`) and the run died
|
|
28
|
+
before writing a trace. The dotted package name and its true parent directory
|
|
29
|
+
are now derived by walking up the `__init__.py` chain.
|
|
30
|
+
- **`fix -o FILE` wrote one hook and silently dropped the rest.** When the
|
|
31
|
+
hidden imports belonged to several top-level packages, `-o` naming a single
|
|
32
|
+
file wrote only the first hook; the others were discarded, leaving a partial
|
|
33
|
+
fix that looked complete. It now refuses and names the packages, pointing at
|
|
34
|
+
a directory or `--spec` instead.
|
|
35
|
+
- **`fix --module` could still write a dead hook file.** Naming a package that
|
|
36
|
+
nothing imports produced `hook-<name>.py` even though PyInstaller would never
|
|
37
|
+
process that name, repeating the exact bug this tool exists to catch. It now
|
|
38
|
+
fails with an explanation instead of emitting an inert artefact.
|
|
39
|
+
- `hookfix fix` on a trace with no hidden imports printed "none of the hidden
|
|
40
|
+
imports are modules PyInstaller processes", which implies there were some.
|
|
41
|
+
It now says plainly that the trace found none.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **`--entry` for `diff` and `fix`.** A trace records the entry-point path from
|
|
46
|
+
the machine that took it, so a trace copied into a different checkout failed
|
|
47
|
+
to re-scan. `--entry path/to/app.py` points at the entry script here.
|
|
48
|
+
- **A child-process notice in the report.** The tracer only sees its own
|
|
49
|
+
interpreter, so a program that spawns another Python process (`subprocess`,
|
|
50
|
+
`multiprocessing`) and does dynamic imports there would otherwise report a
|
|
51
|
+
clean result. The report now warns that child imports are not traced.
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **The generated hook file was never loaded.** `hookfix fix --module app`
|
|
56
|
+
wrote `hook-app.py`, and PyInstaller only reads `hook-NAME.py` while it is
|
|
57
|
+
processing a module called `NAME`. The entry script is never imported as a
|
|
58
|
+
module — PyInstaller knows it as `__main__` — so the hook was silently dead
|
|
59
|
+
and the binary still crashed with `ModuleNotFoundError`. The hook is now keyed
|
|
60
|
+
to the top-level package that owns the hidden imports (`hook-reporters.py`),
|
|
61
|
+
which PyInstaller does process, and `--module` refuses a name that matches the
|
|
62
|
+
entry script. Verified against a real PyInstaller build.
|
|
63
|
+
- When *nothing* imports the top-level package, no hook can ever fire for its
|
|
64
|
+
submodules — that is precisely why they were invisible. `hookfix fix` now
|
|
65
|
+
writes no hook file in that case and prints the `--hidden-import` flags to use
|
|
66
|
+
instead, rather than emitting an artefact that does nothing.
|
|
67
|
+
- **False negative for package entry points.** An app run as `python -m mypkg`
|
|
68
|
+
whose `mypkg/__main__.py` loaded `mypkg.worker` at runtime reported
|
|
69
|
+
`missing: []`, and the frozen binary then died. Two causes: the scanner
|
|
70
|
+
treated a reachable parent package as covering its submodules (so
|
|
71
|
+
`mypkg.worker` looked bundled), and the tracer put the package directory on
|
|
72
|
+
`sys.path`, so the traced program could not even resolve its own package. The
|
|
73
|
+
parent rule is gone and the tracer now handles a package `__main__.py`,
|
|
74
|
+
putting the package's parent on `sys.path` and running it with `runpy`.
|
|
75
|
+
- A trace of a program that crashed is incomplete by construction, but
|
|
76
|
+
`hookfix run` printed a clean report and exited 0, so `missing: []` looked
|
|
77
|
+
like a clean bill of health. It now warns on stderr and propagates the exit
|
|
78
|
+
status.
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- Imports that nothing could resolve are no longer reported as hidden imports.
|
|
83
|
+
The tracer records attempted-but-unresolved names with an empty origin, and
|
|
84
|
+
they were listed as build settings even though there is no file to bundle.
|
|
85
|
+
They now appear under a separate *Unresolved imports* section, which points at
|
|
86
|
+
the environment rather than the build. Namespace packages, which resolve
|
|
87
|
+
without a file, record their search location so they are not mistaken for
|
|
88
|
+
missing.
|
|
89
|
+
- The hidden-import list is grouped by top-level package in the report
|
|
90
|
+
(`yaml.* (17 modules)`), and the copy-paste flags list only the leaves, so a
|
|
91
|
+
package hidden as many submodules no longer buries everything else.
|
|
92
|
+
- The README and package docstring no longer imply Nuitka output. `run` and
|
|
93
|
+
`diff` are freezer-agnostic and their list suits any freezer; only `fix`
|
|
94
|
+
writes PyInstaller-specific files, and that is now stated where it matters.
|
|
95
|
+
|
|
96
|
+
## [0.1.1] - 2026-10-07
|
|
97
|
+
|
|
98
|
+
### Fixed
|
|
99
|
+
|
|
100
|
+
- The diff compared a runtime trace against *every* import in the scanned tree,
|
|
101
|
+
but a freezer only bundles what it can reach from the entry point. A module
|
|
102
|
+
imported inside a dynamically loaded package — the case this tool exists for
|
|
103
|
+
— was treated as already covered and dropped from the report, so applying
|
|
104
|
+
hookfix's complete advice still produced a binary that crashed with
|
|
105
|
+
`ModuleNotFoundError`. The scanner now walks the static import graph from the
|
|
106
|
+
entry point (following relative imports and adding parent packages) and the
|
|
107
|
+
diff compares against that set.
|
|
108
|
+
|
|
109
|
+
### Changed
|
|
110
|
+
|
|
111
|
+
- The JSON trace format is now schema 2: traces record the entry point that was
|
|
112
|
+
traced. `hookfix diff` and `hookfix fix` reject a schema-1 trace instead of
|
|
113
|
+
guessing which modules are reachable. Re-run `hookfix run --trace-out` to
|
|
114
|
+
regenerate an old trace.
|
|
115
|
+
|
|
116
|
+
## [0.1.0] - 2026-10-07
|
|
117
|
+
|
|
118
|
+
First release.
|
|
119
|
+
|
|
120
|
+
### Added
|
|
121
|
+
|
|
122
|
+
- `hookfix run <script>` — run a program under a runtime import tracer and
|
|
123
|
+
report the modules it imported that a static scan cannot see.
|
|
124
|
+
- `hookfix diff <trace>` — re-compare a saved trace against the source tree.
|
|
125
|
+
- `hookfix fix <trace>` — emit a PyInstaller hook file (`hook-<name>.py`) or a
|
|
126
|
+
`hiddenimports = [...]` snippet for an existing `.spec`.
|
|
127
|
+
- Static scanner built on `ast`, reporting dynamic import call sites with exact
|
|
128
|
+
line and column numbers.
|
|
129
|
+
- Versioned JSON trace format (`--trace-out`) so traces can be saved, inspected
|
|
130
|
+
and re-used across machines and CI steps.
|
|
131
|
+
- `py.typed` marker, so downstream type checkers see the package's annotations.
|
|
132
|
+
|
|
133
|
+
### Notes
|
|
134
|
+
|
|
135
|
+
- No runtime dependencies; Python 3.10+.
|
|
136
|
+
- The tracer records what a single execution imported. A code path that never
|
|
137
|
+
runs stays invisible; see "What it does and does not do" in the README.
|
|
138
|
+
|
|
139
|
+
[0.1.1]: https://github.com/sqmyou/hookfix/releases/tag/v0.1.1
|
|
140
|
+
[0.1.0]: https://github.com/sqmyou/hookfix/releases/tag/v0.1.0
|
|
141
|
+
[Unreleased]: https://github.com/sqmyou/hookfix/compare/v0.1.1...HEAD
|
|
@@ -39,11 +39,12 @@ $ pyinstaller --onedir --name demo_broken --paths . main.py
|
|
|
39
39
|
$ ./dist/demo_broken/demo_broken json
|
|
40
40
|
Traceback (most recent call last):
|
|
41
41
|
...
|
|
42
|
-
ModuleNotFoundError: No module named 'reporters'
|
|
42
|
+
ModuleNotFoundError: No module named 'reporters.json_reporter'
|
|
43
43
|
[PYI-2105:ERROR] Failed to execute script 'main' due to unhandled exception!
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
The classic symptom, and the reason this tool exists.
|
|
46
|
+
The classic symptom, and the reason this tool exists. The `reporters` package
|
|
47
|
+
itself made it into the bundle — only the reporter chosen at runtime is missing.
|
|
47
48
|
|
|
48
49
|
## 3. hookfix finds the hidden import
|
|
49
50
|
|
|
@@ -56,7 +57,7 @@ hookfix report
|
|
|
56
57
|
script /tmp/hookfix_demo/main.py
|
|
57
58
|
python 3.13.15
|
|
58
59
|
exit status 0
|
|
59
|
-
scanned
|
|
60
|
+
scanned 5 files, 6 static imports
|
|
60
61
|
observed 18 modules imported at runtime
|
|
61
62
|
|
|
62
63
|
Hidden imports (1)
|
|
@@ -68,9 +69,13 @@ Add them to your build:
|
|
|
68
69
|
|
|
69
70
|
pyinstaller --hidden-import=reporters.json_reporter ...
|
|
70
71
|
|
|
72
|
+
Or generate a hook file for all of them at once:
|
|
73
|
+
|
|
74
|
+
hookfix fix
|
|
75
|
+
|
|
71
76
|
Dynamic import sites (1)
|
|
72
77
|
------------------------
|
|
73
|
-
main.py:
|
|
78
|
+
main.py:31:11 importlib.import_module
|
|
74
79
|
|
|
75
80
|
These call sites are why the imports above are invisible to static analysis.
|
|
76
81
|
```
|
|
@@ -84,18 +89,20 @@ hiddenimports = [
|
|
|
84
89
|
]
|
|
85
90
|
```
|
|
86
91
|
|
|
87
|
-
or a ready-to-use hook file:
|
|
92
|
+
or a ready-to-use hook file, keyed to the package PyInstaller processes:
|
|
88
93
|
|
|
89
94
|
```console
|
|
90
|
-
$ hookfix fix trace.json --path .
|
|
91
|
-
wrote hook-
|
|
95
|
+
$ hookfix fix trace.json --path . -o hooks/
|
|
96
|
+
wrote hooks/hook-reporters.py (1 hidden imports)
|
|
92
97
|
```
|
|
93
98
|
|
|
94
99
|
## 5. The frozen binary works
|
|
95
100
|
|
|
101
|
+
Freeze with only the generated hook applied — no `--hidden-import`:
|
|
102
|
+
|
|
96
103
|
```console
|
|
97
104
|
$ pyinstaller --onedir --name demo_proof --paths . \
|
|
98
|
-
--
|
|
105
|
+
--additional-hooks-dir=hooks main.py
|
|
99
106
|
$ ./dist/demo_proof/demo_proof json
|
|
100
107
|
{
|
|
101
108
|
"status": "ok",
|
|
@@ -103,6 +110,19 @@ $ ./dist/demo_proof/demo_proof json
|
|
|
103
110
|
}
|
|
104
111
|
```
|
|
105
112
|
|
|
113
|
+
`--additional-hooks-dir=hooks` is the whole difference. Without it the binary
|
|
114
|
+
dies with `ModuleNotFoundError`; with it, PyInstaller processes `reporters`,
|
|
115
|
+
reads `hook-reporters.py`, and pulls in `reporters.json_reporter`.
|
|
116
|
+
|
|
117
|
+
Two things make this work, and both matter. The hook is named after
|
|
118
|
+
`reporters`, a module PyInstaller processes — a hook named `hook-main.py` would
|
|
119
|
+
never be read, because PyInstaller knows the entry script as `__main__`. And the
|
|
120
|
+
app imports `reporters` itself, so there is a module for the hook to attach to.
|
|
121
|
+
|
|
122
|
+
If your app never imports the package, no hook can help: pass the module with
|
|
123
|
+
`--hidden-import` instead, which is unconditional. `hookfix fix` says so and
|
|
124
|
+
prints the exact flags when that happens.
|
|
125
|
+
|
|
106
126
|
## What the demo also shows: the honest boundary
|
|
107
127
|
|
|
108
128
|
The app has a second reporter, `text_reporter`, that the run above never
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hookfix
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Find the hidden imports that break your frozen Python app.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sqmyou/hookfix
|
|
6
6
|
Project-URL: Repository, https://github.com/sqmyou/hookfix
|
|
@@ -25,6 +25,7 @@ Classifier: Topic :: Software Development :: Quality Assurance
|
|
|
25
25
|
Requires-Python: >=3.10
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pyinstaller>=6; extra == 'dev'
|
|
28
29
|
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
29
30
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
30
31
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
@@ -35,8 +36,8 @@ Description-Content-Type: text/markdown
|
|
|
35
36
|
**Find the hidden imports that break your frozen Python app.**
|
|
36
37
|
|
|
37
38
|
[](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
|
|
38
|
-
[](https://pypi.org/project/hookfix/)
|
|
39
|
-
[](https://pypi.org/project/hookfix/)
|
|
39
|
+
[](https://pypi.org/project/hookfix/)
|
|
40
|
+
[](https://pypi.org/project/hookfix/)
|
|
40
41
|
[](LICENSE)
|
|
41
42
|
|
|
42
43
|
---
|
|
@@ -85,6 +86,9 @@ Or generate a hook file for all of them at once:
|
|
|
85
86
|
|
|
86
87
|
hookfix fix
|
|
87
88
|
|
|
89
|
+
(That only works for modules PyInstaller actually processes. See
|
|
90
|
+
[`hookfix fix`](#hookfix-fix--generate-build-configuration) for the details.)
|
|
91
|
+
|
|
88
92
|
Dynamic import sites (2)
|
|
89
93
|
------------------------
|
|
90
94
|
app.py:41:12 importlib.import_module
|
|
@@ -163,14 +167,53 @@ you want to trace once and check the result from a different step.
|
|
|
163
167
|
### `hookfix fix` — generate build configuration
|
|
164
168
|
|
|
165
169
|
```console
|
|
166
|
-
hookfix fix trace.json
|
|
167
|
-
hookfix fix trace.json
|
|
168
|
-
hookfix fix trace.json --
|
|
170
|
+
hookfix fix trace.json # -> hook-<package>.py on stdout
|
|
171
|
+
hookfix fix trace.json -o hooks/ # -> hooks/hook-<package>.py
|
|
172
|
+
hookfix fix trace.json --module reporters # -> hook-reporters.py (explicit name)
|
|
173
|
+
hookfix fix trace.json --spec # -> hiddenimports = [...] snippet
|
|
169
174
|
```
|
|
170
175
|
|
|
171
|
-
`--
|
|
172
|
-
|
|
173
|
-
|
|
176
|
+
`--spec` prints just the `hiddenimports = [...]` list to drop into an existing
|
|
177
|
+
`.spec` file, or to pass as `--hidden-import` flags. It always works.
|
|
178
|
+
|
|
179
|
+
> **The generated fix is PyInstaller-specific.** `fix` writes PyInstaller hook
|
|
180
|
+
> files and `.spec` snippets. `run` and `diff` are freezer-agnostic: the list
|
|
181
|
+
> they report is exactly the set of modules missing from the build, and a Nuitka
|
|
182
|
+
> user can feed that list to `--include-module` (or `--include-package`). There
|
|
183
|
+
> is no Nuitka config writer yet.
|
|
184
|
+
|
|
185
|
+
The hook file is the reusable form of `--hidden-import`, but it comes with a
|
|
186
|
+
constraint worth understanding, because it is the difference between a build
|
|
187
|
+
that works and one that fails silently:
|
|
188
|
+
|
|
189
|
+
> PyInstaller reads `hook-NAME.py` only while it is processing a module called
|
|
190
|
+
> `NAME`. A hook named after the entry script is never read — PyInstaller knows
|
|
191
|
+
> the entry script as `__main__`, not by its file name.
|
|
192
|
+
|
|
193
|
+
So a hook can only carry imports for a module PyInstaller already imports. The
|
|
194
|
+
hidden imports are submodules (`reporters.json_reporter`), so `hookfix` keys the
|
|
195
|
+
hook to the top-level package that owns them (`reporters`). That works when your
|
|
196
|
+
program imports the package: PyInstaller processes `reporters`, reads
|
|
197
|
+
`hook-reporters.py`, and picks up the submodule.
|
|
198
|
+
|
|
199
|
+
It cannot work when *nothing* imports the package — which is precisely why the
|
|
200
|
+
submodule was invisible in the first place. In that case `hookfix` writes no
|
|
201
|
+
hook file and tells you to use `--hidden-import` instead, because a hook it
|
|
202
|
+
wrote would be dead code:
|
|
203
|
+
|
|
204
|
+
```console
|
|
205
|
+
$ hookfix fix trace.json
|
|
206
|
+
no hook file written: none of the hidden imports are modules PyInstaller
|
|
207
|
+
processes, so a hook would never fire.
|
|
208
|
+
|
|
209
|
+
1 module(s) cannot be covered by a hook (nothing imports them, so PyInstaller
|
|
210
|
+
never processes them). Pass these instead:
|
|
211
|
+
|
|
212
|
+
pyinstaller --hidden-import=reporters.json_reporter ...
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`--module NAME` overrides the name. It refuses a name that matches the entry
|
|
216
|
+
script, since that hook would never be read.
|
|
174
217
|
|
|
175
218
|
## What it does and does not do
|
|
176
219
|
|
|
@@ -178,11 +221,28 @@ into an existing `.spec` file.
|
|
|
178
221
|
any runtime tool. If a code path never executed — a plugin for a mode you did
|
|
179
222
|
not exercise, a platform-specific branch — its imports will not appear.
|
|
180
223
|
|
|
224
|
+
**Child processes are not traced.** The tracer sees only the interpreter it
|
|
225
|
+
runs in. If your program spawns another Python process (`subprocess`,
|
|
226
|
+
`multiprocessing`) and that child does dynamic imports, those imports are not in
|
|
227
|
+
the report. The report prints a *Child processes were used* notice when it sees
|
|
228
|
+
`subprocess` or `multiprocessing` was imported, but it cannot tell whether a
|
|
229
|
+
child actually ran. Trace a child script directly if it does the dynamic
|
|
230
|
+
importing.
|
|
231
|
+
|
|
232
|
+
**A moved trace needs `--entry`.** A trace records the entry-point path from the
|
|
233
|
+
machine it was taken on, so re-scanning it from a different checkout fails with
|
|
234
|
+
`entry point does not exist`. Pass `--entry path/to/app.py` to `diff` or `fix`
|
|
235
|
+
to point at the entry script here.
|
|
236
|
+
|
|
181
237
|
So: run `hookfix` against the widest set of inputs you can, ideally the same
|
|
182
238
|
ones your smoke tests use. The output tells you which call sites are dynamic, so
|
|
183
239
|
you can see what you might have missed. Treat the generated hook file as a
|
|
184
240
|
starting point to review, not as a finished artefact — the header says as much.
|
|
185
241
|
|
|
242
|
+
Imports that nothing could resolve are listed separately, under *Unresolved
|
|
243
|
+
imports*. They are not build settings: there is no file to bundle, so the fix is
|
|
244
|
+
to install the module (or accept that it is built in and always available).
|
|
245
|
+
|
|
186
246
|
It also cannot tell you about data files, native libraries, or metadata that a
|
|
187
247
|
freezer might drop. It is specifically about imports.
|
|
188
248
|
|
|
@@ -190,8 +250,10 @@ freezer might drop. It is specifically about imports.
|
|
|
190
250
|
|
|
191
251
|
1. **Trace.** The CLI spawns a child process (`python -m hookfix._bootstrap`)
|
|
192
252
|
that installs a recording meta path finder and then runs your script with
|
|
193
|
-
`runpy`, mimicking a plain `python script.py` invocation
|
|
194
|
-
|
|
253
|
+
`runpy`, mimicking a plain `python script.py` invocation — or
|
|
254
|
+
`python -m package`, when the entry point is a package's `__main__.py`.
|
|
255
|
+
Every resolved module is logged as `name<TAB>origin`, and a module that
|
|
256
|
+
resolves without a file (a namespace package) logs its search location.
|
|
195
257
|
|
|
196
258
|
2. **Scan.** `hookfix` walks the source tree with `ast`, collects every
|
|
197
259
|
`import` statement, and records the location of every dynamic import call
|
|
@@ -202,7 +264,8 @@ freezer might drop. It is specifically about imports.
|
|
|
202
264
|
|
|
203
265
|
3. **Diff.** The runtime modules minus the reachable ones are the hidden
|
|
204
266
|
imports. Standard-library modules are split out (a freezer bundles those
|
|
205
|
-
anyway)
|
|
267
|
+
anyway), import-machinery internals are filtered as noise, and names that
|
|
268
|
+
resolved to no file are reported as unresolved rather than hidden.
|
|
206
269
|
|
|
207
270
|
4. **Report.** The remainder is printed, or rendered as a hook file or spec
|
|
208
271
|
snippet.
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
**Find the hidden imports that break your frozen Python app.**
|
|
4
4
|
|
|
5
5
|
[](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
|
|
6
|
-
[](https://pypi.org/project/hookfix/)
|
|
7
|
-
[](https://pypi.org/project/hookfix/)
|
|
6
|
+
[](https://pypi.org/project/hookfix/)
|
|
7
|
+
[](https://pypi.org/project/hookfix/)
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
10
|
---
|
|
@@ -53,6 +53,9 @@ Or generate a hook file for all of them at once:
|
|
|
53
53
|
|
|
54
54
|
hookfix fix
|
|
55
55
|
|
|
56
|
+
(That only works for modules PyInstaller actually processes. See
|
|
57
|
+
[`hookfix fix`](#hookfix-fix--generate-build-configuration) for the details.)
|
|
58
|
+
|
|
56
59
|
Dynamic import sites (2)
|
|
57
60
|
------------------------
|
|
58
61
|
app.py:41:12 importlib.import_module
|
|
@@ -131,14 +134,53 @@ you want to trace once and check the result from a different step.
|
|
|
131
134
|
### `hookfix fix` — generate build configuration
|
|
132
135
|
|
|
133
136
|
```console
|
|
134
|
-
hookfix fix trace.json
|
|
135
|
-
hookfix fix trace.json
|
|
136
|
-
hookfix fix trace.json --
|
|
137
|
+
hookfix fix trace.json # -> hook-<package>.py on stdout
|
|
138
|
+
hookfix fix trace.json -o hooks/ # -> hooks/hook-<package>.py
|
|
139
|
+
hookfix fix trace.json --module reporters # -> hook-reporters.py (explicit name)
|
|
140
|
+
hookfix fix trace.json --spec # -> hiddenimports = [...] snippet
|
|
137
141
|
```
|
|
138
142
|
|
|
139
|
-
`--
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
`--spec` prints just the `hiddenimports = [...]` list to drop into an existing
|
|
144
|
+
`.spec` file, or to pass as `--hidden-import` flags. It always works.
|
|
145
|
+
|
|
146
|
+
> **The generated fix is PyInstaller-specific.** `fix` writes PyInstaller hook
|
|
147
|
+
> files and `.spec` snippets. `run` and `diff` are freezer-agnostic: the list
|
|
148
|
+
> they report is exactly the set of modules missing from the build, and a Nuitka
|
|
149
|
+
> user can feed that list to `--include-module` (or `--include-package`). There
|
|
150
|
+
> is no Nuitka config writer yet.
|
|
151
|
+
|
|
152
|
+
The hook file is the reusable form of `--hidden-import`, but it comes with a
|
|
153
|
+
constraint worth understanding, because it is the difference between a build
|
|
154
|
+
that works and one that fails silently:
|
|
155
|
+
|
|
156
|
+
> PyInstaller reads `hook-NAME.py` only while it is processing a module called
|
|
157
|
+
> `NAME`. A hook named after the entry script is never read — PyInstaller knows
|
|
158
|
+
> the entry script as `__main__`, not by its file name.
|
|
159
|
+
|
|
160
|
+
So a hook can only carry imports for a module PyInstaller already imports. The
|
|
161
|
+
hidden imports are submodules (`reporters.json_reporter`), so `hookfix` keys the
|
|
162
|
+
hook to the top-level package that owns them (`reporters`). That works when your
|
|
163
|
+
program imports the package: PyInstaller processes `reporters`, reads
|
|
164
|
+
`hook-reporters.py`, and picks up the submodule.
|
|
165
|
+
|
|
166
|
+
It cannot work when *nothing* imports the package — which is precisely why the
|
|
167
|
+
submodule was invisible in the first place. In that case `hookfix` writes no
|
|
168
|
+
hook file and tells you to use `--hidden-import` instead, because a hook it
|
|
169
|
+
wrote would be dead code:
|
|
170
|
+
|
|
171
|
+
```console
|
|
172
|
+
$ hookfix fix trace.json
|
|
173
|
+
no hook file written: none of the hidden imports are modules PyInstaller
|
|
174
|
+
processes, so a hook would never fire.
|
|
175
|
+
|
|
176
|
+
1 module(s) cannot be covered by a hook (nothing imports them, so PyInstaller
|
|
177
|
+
never processes them). Pass these instead:
|
|
178
|
+
|
|
179
|
+
pyinstaller --hidden-import=reporters.json_reporter ...
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`--module NAME` overrides the name. It refuses a name that matches the entry
|
|
183
|
+
script, since that hook would never be read.
|
|
142
184
|
|
|
143
185
|
## What it does and does not do
|
|
144
186
|
|
|
@@ -146,11 +188,28 @@ into an existing `.spec` file.
|
|
|
146
188
|
any runtime tool. If a code path never executed — a plugin for a mode you did
|
|
147
189
|
not exercise, a platform-specific branch — its imports will not appear.
|
|
148
190
|
|
|
191
|
+
**Child processes are not traced.** The tracer sees only the interpreter it
|
|
192
|
+
runs in. If your program spawns another Python process (`subprocess`,
|
|
193
|
+
`multiprocessing`) and that child does dynamic imports, those imports are not in
|
|
194
|
+
the report. The report prints a *Child processes were used* notice when it sees
|
|
195
|
+
`subprocess` or `multiprocessing` was imported, but it cannot tell whether a
|
|
196
|
+
child actually ran. Trace a child script directly if it does the dynamic
|
|
197
|
+
importing.
|
|
198
|
+
|
|
199
|
+
**A moved trace needs `--entry`.** A trace records the entry-point path from the
|
|
200
|
+
machine it was taken on, so re-scanning it from a different checkout fails with
|
|
201
|
+
`entry point does not exist`. Pass `--entry path/to/app.py` to `diff` or `fix`
|
|
202
|
+
to point at the entry script here.
|
|
203
|
+
|
|
149
204
|
So: run `hookfix` against the widest set of inputs you can, ideally the same
|
|
150
205
|
ones your smoke tests use. The output tells you which call sites are dynamic, so
|
|
151
206
|
you can see what you might have missed. Treat the generated hook file as a
|
|
152
207
|
starting point to review, not as a finished artefact — the header says as much.
|
|
153
208
|
|
|
209
|
+
Imports that nothing could resolve are listed separately, under *Unresolved
|
|
210
|
+
imports*. They are not build settings: there is no file to bundle, so the fix is
|
|
211
|
+
to install the module (or accept that it is built in and always available).
|
|
212
|
+
|
|
154
213
|
It also cannot tell you about data files, native libraries, or metadata that a
|
|
155
214
|
freezer might drop. It is specifically about imports.
|
|
156
215
|
|
|
@@ -158,8 +217,10 @@ freezer might drop. It is specifically about imports.
|
|
|
158
217
|
|
|
159
218
|
1. **Trace.** The CLI spawns a child process (`python -m hookfix._bootstrap`)
|
|
160
219
|
that installs a recording meta path finder and then runs your script with
|
|
161
|
-
`runpy`, mimicking a plain `python script.py` invocation
|
|
162
|
-
|
|
220
|
+
`runpy`, mimicking a plain `python script.py` invocation — or
|
|
221
|
+
`python -m package`, when the entry point is a package's `__main__.py`.
|
|
222
|
+
Every resolved module is logged as `name<TAB>origin`, and a module that
|
|
223
|
+
resolves without a file (a namespace package) logs its search location.
|
|
163
224
|
|
|
164
225
|
2. **Scan.** `hookfix` walks the source tree with `ast`, collects every
|
|
165
226
|
`import` statement, and records the location of every dynamic import call
|
|
@@ -170,7 +231,8 @@ freezer might drop. It is specifically about imports.
|
|
|
170
231
|
|
|
171
232
|
3. **Diff.** The runtime modules minus the reachable ones are the hidden
|
|
172
233
|
imports. Standard-library modules are split out (a freezer bundles those
|
|
173
|
-
anyway)
|
|
234
|
+
anyway), import-machinery internals are filtered as noise, and names that
|
|
235
|
+
resolved to no file are reported as unresolved rather than hidden.
|
|
174
236
|
|
|
175
237
|
4. **Report.** The remainder is printed, or rendered as a hook file or spec
|
|
176
238
|
snippet.
|
|
@@ -12,6 +12,12 @@ from __future__ import annotations
|
|
|
12
12
|
import importlib
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
+
# The package itself is imported, so PyInstaller sees it and will read a
|
|
16
|
+
# generated ``hook-reporters.py``. Which *reporter* runs is still decided at
|
|
17
|
+
# runtime, and that module is never named in an import statement -- which is
|
|
18
|
+
# exactly what a static scan cannot see.
|
|
19
|
+
import reporters # noqa: F401
|
|
20
|
+
|
|
15
21
|
# A registry mapping a user-facing name to a module path. Nothing here is a
|
|
16
22
|
# literal ``import``, so a static analyser cannot see what will be loaded.
|
|
17
23
|
REPORTERS = {
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hookfix"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Find the hidden imports that break your frozen Python app."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -45,6 +45,10 @@ dev = [
|
|
|
45
45
|
"pytest-cov>=5",
|
|
46
46
|
"mypy>=1.10",
|
|
47
47
|
"ruff>=0.6",
|
|
48
|
+
# The integration test freezes a real app and runs the binary. Without
|
|
49
|
+
# PyInstaller installed it is skipped, which is how a dead hook file shipped
|
|
50
|
+
# unnoticed. Keep it here so CI exercises the freeze.
|
|
51
|
+
"pyinstaller>=6",
|
|
48
52
|
]
|
|
49
53
|
|
|
50
54
|
[project.urls]
|
|
@@ -58,6 +62,7 @@ dev = [
|
|
|
58
62
|
"pytest-cov>=5",
|
|
59
63
|
"mypy>=1.10",
|
|
60
64
|
"ruff>=0.6",
|
|
65
|
+
"pyinstaller>=6",
|
|
61
66
|
]
|
|
62
67
|
|
|
63
68
|
[tool.hatch.version]
|
|
@@ -8,9 +8,10 @@ the user's machine and not on yours.
|
|
|
8
8
|
hookfix runs your program once under a meta path import tracer, records every
|
|
9
9
|
module the interpreter actually imports, and compares that against the imports
|
|
10
10
|
a freezer can reach from your entry point. The difference is exactly the set of
|
|
11
|
-
modules your build is missing.
|
|
11
|
+
modules your build is missing. That report is freezer-agnostic; ``hookfix fix``
|
|
12
|
+
renders it as PyInstaller hook files or ``.spec`` snippets.
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
|
-
__version__ = "0.1.
|
|
15
|
+
__version__ = "0.1.2"
|
|
15
16
|
|
|
16
17
|
__all__ = ["__version__"]
|