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.
Files changed (65) hide show
  1. {hookfix-0.1.1 → hookfix-0.1.2}/AGENTS.md +27 -2
  2. hookfix-0.1.2/CHANGELOG.md +141 -0
  3. {hookfix-0.1.1 → hookfix-0.1.2}/DEMO.md +28 -8
  4. {hookfix-0.1.1 → hookfix-0.1.2}/PKG-INFO +75 -12
  5. {hookfix-0.1.1 → hookfix-0.1.2}/README.md +73 -11
  6. {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/main.py +6 -0
  7. {hookfix-0.1.1 → hookfix-0.1.2}/pyproject.toml +6 -1
  8. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/__init__.py +3 -2
  9. hookfix-0.1.2/src/hookfix/_bootstrap.py +121 -0
  10. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/cli.py +148 -14
  11. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/differ.py +44 -12
  12. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/model.py +5 -0
  13. hookfix-0.1.2/src/hookfix/report.py +164 -0
  14. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/spec_writer.py +23 -6
  15. hookfix-0.1.2/tests/conftest.py +34 -0
  16. hookfix-0.1.2/tests/fixtures/hooked_app/app.py +13 -0
  17. hookfix-0.1.2/tests/fixtures/hooked_app/dynpkg/__init__.py +1 -0
  18. hookfix-0.1.2/tests/fixtures/nested_app/dynpkg/helper.py +1 -0
  19. hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/__init__.py +0 -0
  20. hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/b/__init__.py +0 -0
  21. hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/b/__main__.py +3 -0
  22. hookfix-0.1.2/tests/fixtures/nested_pkg_app/a/b/worker.py +1 -0
  23. hookfix-0.1.2/tests/fixtures/pkg_app/mypkg/__init__.py +1 -0
  24. hookfix-0.1.2/tests/fixtures/pkg_app/mypkg/__main__.py +6 -0
  25. hookfix-0.1.2/tests/fixtures/pkg_app/mypkg/worker.py +1 -0
  26. hookfix-0.1.2/tests/fixtures/private_app/_priv/__init__.py +1 -0
  27. hookfix-0.1.2/tests/fixtures/private_app/_priv/_core.py +1 -0
  28. hookfix-0.1.2/tests/fixtures/private_app/app.py +7 -0
  29. hookfix-0.1.2/tests/fixtures/two_pkgs_app/alpha/__init__.py +1 -0
  30. hookfix-0.1.2/tests/fixtures/two_pkgs_app/alpha/one.py +1 -0
  31. hookfix-0.1.2/tests/fixtures/two_pkgs_app/app.py +14 -0
  32. hookfix-0.1.2/tests/fixtures/two_pkgs_app/beta/__init__.py +1 -0
  33. hookfix-0.1.2/tests/fixtures/two_pkgs_app/beta/two.py +1 -0
  34. hookfix-0.1.2/tests/test_cli.py +461 -0
  35. hookfix-0.1.2/tests/test_differ.py +181 -0
  36. hookfix-0.1.2/tests/test_freeze_integration.py +154 -0
  37. {hookfix-0.1.1 → hookfix-0.1.2}/tests/test_spec_writer.py +15 -7
  38. hookfix-0.1.1/CHANGELOG.md +0 -54
  39. hookfix-0.1.1/src/hookfix/_bootstrap.py +0 -76
  40. hookfix-0.1.1/src/hookfix/report.py +0 -89
  41. hookfix-0.1.1/tests/conftest.py +0 -14
  42. hookfix-0.1.1/tests/test_cli.py +0 -195
  43. hookfix-0.1.1/tests/test_differ.py +0 -83
  44. {hookfix-0.1.1 → hookfix-0.1.2}/.github/workflows/ci.yml +0 -0
  45. {hookfix-0.1.1 → hookfix-0.1.2}/.github/workflows/publish.yml +0 -0
  46. {hookfix-0.1.1 → hookfix-0.1.2}/.gitignore +0 -0
  47. {hookfix-0.1.1 → hookfix-0.1.2}/LICENSE +0 -0
  48. {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/reporters/__init__.py +0 -0
  49. {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/reporters/json_reporter.py +0 -0
  50. {hookfix-0.1.1 → hookfix-0.1.2}/examples/demo_app/reporters/text_reporter.py +0 -0
  51. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/__main__.py +0 -0
  52. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/errors.py +0 -0
  53. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/py.typed +0 -0
  54. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/scanner.py +0 -0
  55. {hookfix-0.1.1 → hookfix-0.1.2}/src/hookfix/tracer.py +0 -0
  56. {hookfix-0.1.1 → hookfix-0.1.2}/tests/__init__.py +0 -0
  57. {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/app.py +0 -0
  58. {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/plugins/__init__.py +0 -0
  59. {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/plugins/archive.py +0 -0
  60. {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/dynamic_app/plugins/report.py +0 -0
  61. {hookfix-0.1.1/tests/fixtures/nested_app → hookfix-0.1.2/tests/fixtures/hooked_app}/dynpkg/helper.py +0 -0
  62. {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/nested_app/app.py +0 -0
  63. {hookfix-0.1.1 → hookfix-0.1.2}/tests/fixtures/nested_app/dynpkg/__init__.py +0 -0
  64. {hookfix-0.1.1 → hookfix-0.1.2}/tests/test_scanner.py +0 -0
  65. {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/Nuitka configuration for the difference.
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 (28 tests)
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 4 files, 4 static imports
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:25:12 importlib.import_module
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 . --module main -o hook-main.py
91
- wrote hook-main.py (1 hidden imports)
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
- --hidden-import=reporters.json_reporter main.py
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.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
  [![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/)
39
+ [![PyPI](https://img.shields.io/pypi/v/hookfix.svg?style=flat)](https://pypi.org/project/hookfix/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/hookfix.svg?style=flat)](https://pypi.org/project/hookfix/)
40
41
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 --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
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
- `--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.
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. Every resolved
194
- module is logged as `name<TAB>origin`.
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) and import-machinery internals are filtered as noise.
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
  [![CI](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml/badge.svg)](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
6
- [![PyPI](https://img.shields.io/pypi/v/hookfix.svg)](https://pypi.org/project/hookfix/)
7
- [![Python versions](https://img.shields.io/pypi/pyversions/hookfix.svg)](https://pypi.org/project/hookfix/)
6
+ [![PyPI](https://img.shields.io/pypi/v/hookfix.svg?style=flat)](https://pypi.org/project/hookfix/)
7
+ [![Python versions](https://img.shields.io/pypi/pyversions/hookfix.svg?style=flat)](https://pypi.org/project/hookfix/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 --module app # -> hook-app.py
135
- hookfix fix trace.json --module app -o out/ # -> out/hook-app.py
136
- hookfix fix trace.json --spec # -> hiddenimports = [...] snippet
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
- `--module app` writes a PyInstaller hook file, the reusable form of
140
- `--hidden-import`. `--spec` prints just the `hiddenimports = [...]` list to drop
141
- into an existing `.spec` file.
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. Every resolved
162
- module is logged as `name<TAB>origin`.
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) and import-machinery internals are filtered as noise.
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.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.1"
15
+ __version__ = "0.1.2"
15
16
 
16
17
  __all__ = ["__version__"]