hookfix 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. hookfix-0.1.0/.github/workflows/ci.yml +47 -0
  2. hookfix-0.1.0/.github/workflows/publish.yml +48 -0
  3. hookfix-0.1.0/.gitignore +38 -0
  4. hookfix-0.1.0/AGENTS.md +84 -0
  5. hookfix-0.1.0/CHANGELOG.md +31 -0
  6. hookfix-0.1.0/DEMO.md +120 -0
  7. hookfix-0.1.0/LICENSE +21 -0
  8. hookfix-0.1.0/PKG-INFO +225 -0
  9. hookfix-0.1.0/README.md +193 -0
  10. hookfix-0.1.0/examples/demo_app/main.py +36 -0
  11. hookfix-0.1.0/examples/demo_app/reporters/__init__.py +1 -0
  12. hookfix-0.1.0/examples/demo_app/reporters/json_reporter.py +7 -0
  13. hookfix-0.1.0/examples/demo_app/reporters/text_reporter.py +5 -0
  14. hookfix-0.1.0/pyproject.toml +107 -0
  15. hookfix-0.1.0/src/hookfix/__init__.py +15 -0
  16. hookfix-0.1.0/src/hookfix/__main__.py +6 -0
  17. hookfix-0.1.0/src/hookfix/_bootstrap.py +76 -0
  18. hookfix-0.1.0/src/hookfix/cli.py +267 -0
  19. hookfix-0.1.0/src/hookfix/differ.py +66 -0
  20. hookfix-0.1.0/src/hookfix/errors.py +19 -0
  21. hookfix-0.1.0/src/hookfix/model.py +135 -0
  22. hookfix-0.1.0/src/hookfix/py.typed +0 -0
  23. hookfix-0.1.0/src/hookfix/report.py +89 -0
  24. hookfix-0.1.0/src/hookfix/scanner.py +164 -0
  25. hookfix-0.1.0/src/hookfix/spec_writer.py +41 -0
  26. hookfix-0.1.0/src/hookfix/tracer.py +125 -0
  27. hookfix-0.1.0/tests/__init__.py +1 -0
  28. hookfix-0.1.0/tests/conftest.py +9 -0
  29. hookfix-0.1.0/tests/fixtures/dynamic_app/app.py +34 -0
  30. hookfix-0.1.0/tests/fixtures/dynamic_app/plugins/__init__.py +1 -0
  31. hookfix-0.1.0/tests/fixtures/dynamic_app/plugins/archive.py +9 -0
  32. hookfix-0.1.0/tests/fixtures/dynamic_app/plugins/report.py +5 -0
  33. hookfix-0.1.0/tests/test_cli.py +147 -0
  34. hookfix-0.1.0/tests/test_differ.py +46 -0
  35. hookfix-0.1.0/tests/test_scanner.py +62 -0
  36. hookfix-0.1.0/tests/test_spec_writer.py +34 -0
  37. hookfix-0.1.0/tests/test_tracer.py +60 -0
@@ -0,0 +1,47 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ${{ matrix.os }}
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ os: [ubuntu-latest]
19
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
20
+ include:
21
+ - os: macos-latest
22
+ python-version: "3.12"
23
+ - os: windows-latest
24
+ python-version: "3.12"
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: actions/setup-python@v5
28
+ with:
29
+ python-version: ${{ matrix.python-version }}
30
+ - name: Install
31
+ run: python -m pip install -e ".[dev]"
32
+ - name: Test
33
+ run: python -m pytest -q
34
+
35
+ lint:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: actions/setup-python@v5
40
+ with:
41
+ python-version: "3.12"
42
+ - name: Install
43
+ run: python -m pip install -e ".[dev]"
44
+ - name: Ruff
45
+ run: python -m ruff check src tests
46
+ - name: Mypy
47
+ run: python -m mypy
@@ -0,0 +1,48 @@
1
+ name: Publish
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - name: Build
20
+ run: |
21
+ python -m pip install --upgrade build
22
+ python -m build
23
+ - name: Check artefacts
24
+ run: |
25
+ python -m pip install --upgrade twine
26
+ python -m twine check dist/*
27
+ - uses: actions/upload-artifact@v4
28
+ with:
29
+ name: dist
30
+ path: dist/
31
+
32
+ publish:
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ # Trusted Publishing: PyPI verifies this workflow's OIDC identity, so no
36
+ # API token is stored in the repository. Configure the publisher at
37
+ # https://pypi.org/manage/account/publishing/ before the first release.
38
+ environment:
39
+ name: pypi
40
+ url: https://pypi.org/project/hookfix/
41
+ permissions:
42
+ id-token: write
43
+ steps:
44
+ - uses: actions/download-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,38 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Distribution / packaging
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .eggs/
11
+ wheels/
12
+
13
+ # Unit test / coverage reports
14
+ .pytest_cache/
15
+ .coverage
16
+ .coverage.*
17
+ htmlcov/
18
+ .tox/
19
+ .nox/
20
+ coverage.xml
21
+
22
+ # Environments
23
+ .venv/
24
+ venv/
25
+ env/
26
+ ENV/
27
+
28
+ # Tooling caches
29
+ .mypy_cache/
30
+ .ruff_cache/
31
+
32
+ # hookfix's own working directory
33
+ .hookfix/
34
+
35
+ # Editors / OS
36
+ .idea/
37
+ .vscode/
38
+ .DS_Store
@@ -0,0 +1,84 @@
1
+ # AGENTS.md
2
+
3
+ Repository notes for AI agents and contributors working on hookfix.
4
+
5
+ ## What this project is
6
+
7
+ `hookfix` finds the imports that break frozen Python apps. It runs a program
8
+ under a runtime import tracer, subtracts the imports a static scan can see, and
9
+ emits PyInstaller/Nuitka configuration for the difference.
10
+
11
+ ## Commands
12
+
13
+ ```console
14
+ python -m pip install -e ".[dev]" # install with test/lint tools
15
+ python -m pytest # test suite (28 tests)
16
+ python -m ruff check src tests # lint
17
+ python -m mypy # strict type check (src/hookfix only)
18
+ ```
19
+
20
+ All three must pass before a change is considered done. CI runs them on
21
+ Linux (3.10-3.13) plus macOS and Windows (3.12).
22
+
23
+ ## Layout
24
+
25
+ - `src/hookfix/` — src-layout package. No runtime dependencies; standard
26
+ library only.
27
+ - `src/hookfix/tracer.py` — the technical heart. Records imports via a
28
+ `sys.meta_path` finder.
29
+ - `src/hookfix/_bootstrap.py` — runs the target script in a child process.
30
+ Deliberately imports nothing from the rest of hookfix, so its own
31
+ dependencies are not mistaken for the user's.
32
+ - `src/hookfix/scanner.py` — `ast`-based static scan.
33
+ - `src/hookfix/differ.py` — runtime trace minus static imports.
34
+ - `src/hookfix/spec_writer.py` — hook file / spec snippet rendering.
35
+ - `tests/fixtures/dynamic_app/` — fixture that loads a plugin through
36
+ `importlib.import_module`.
37
+ - `examples/demo_app/` — the app used in `DEMO.md`.
38
+
39
+ ## Things worth knowing before you change code
40
+
41
+ 1. **Do not switch the tracer back to CPython audit hooks.**
42
+ `importlib.import_module` calls `_gcd_import` directly and never raises the
43
+ `import` audit event, so audit hooks miss the exact dynamic case this tool
44
+ exists to catch. Verified on Python 3.13. A `sys.meta_path` finder sees
45
+ every path. `README.md` explains this with a runnable example.
46
+
47
+ 2. **Report fully-qualified module names.** PyInstaller needs
48
+ `--hidden-import=reporters.json_reporter`; the bare package name does not
49
+ pull in a submodule that nothing imports statically. This was a real bug,
50
+ caught only by testing against an actual PyInstaller build.
51
+
52
+ 3. **Verify against a real freeze.** The unit tests use a fixture, but the
53
+ bugs that mattered were found by actually building with PyInstaller. If you
54
+ change the differ, scanner or spec writer, freeze `examples/demo_app` and
55
+ confirm the binary runs.
56
+
57
+ 4. **The tool reports one execution.** An unexercised code path stays
58
+ invisible. That is the contract, documented in `README.md` and shown in
59
+ `DEMO.md`. Do not paper over it with guesswork.
60
+
61
+ ## Releasing
62
+
63
+ `main` is the release branch. To cut a release:
64
+
65
+ 1. Bump `__version__` in `src/hookfix/__init__.py` (hatchling reads it from
66
+ there) and add a dated section to `CHANGELOG.md`.
67
+ 2. `python -m build && python -m twine check dist/*` — both must pass.
68
+ 3. Commit, push, then `git tag -a vX.Y.Z -m "..." && git push origin vX.Y.Z`.
69
+ 4. `gh release create vX.Y.Z --title ... --notes ...` — publishing the release
70
+ triggers `.github/workflows/publish.yml`, which builds and uploads to PyPI.
71
+
72
+ Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored here.
73
+ This requires a one-time setup on PyPI that only a human can do: at
74
+ <https://pypi.org/manage/account/publishing/>, add a pending publisher with
75
+
76
+ - PyPI project name: `hookfix`
77
+ - Owner: `sqmyou`
78
+ - Repository: `hookfix`
79
+ - Workflow name: `publish.yml`
80
+ - Environment: `pypi`
81
+
82
+ Until that exists, the `publish` job fails with `invalid-publisher`. The
83
+ `build` job still succeeds, so the artefacts can be checked without PyPI.
84
+
@@ -0,0 +1,31 @@
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.0] - 2026-10-07
8
+
9
+ First release.
10
+
11
+ ### Added
12
+
13
+ - `hookfix run <script>` — run a program under a runtime import tracer and
14
+ report the modules it imported that a static scan cannot see.
15
+ - `hookfix diff <trace>` — re-compare a saved trace against the source tree.
16
+ - `hookfix fix <trace>` — emit a PyInstaller hook file (`hook-<name>.py`) or a
17
+ `hiddenimports = [...]` snippet for an existing `.spec`.
18
+ - Static scanner built on `ast`, reporting dynamic import call sites with exact
19
+ line and column numbers.
20
+ - Versioned JSON trace format (`--trace-out`) so traces can be saved, inspected
21
+ and re-used across machines and CI steps.
22
+ - `py.typed` marker, so downstream type checkers see the package's annotations.
23
+
24
+ ### Notes
25
+
26
+ - No runtime dependencies; Python 3.10+.
27
+ - The tracer records what a single execution imported. A code path that never
28
+ runs stays invisible; see "What it does and does not do" in the README.
29
+
30
+ [0.1.0]: https://github.com/sqmyou/hookfix/releases/tag/v0.1.0
31
+ [Unreleased]: https://github.com/sqmyou/hookfix/compare/v0.1.0...HEAD
hookfix-0.1.0/DEMO.md ADDED
@@ -0,0 +1,120 @@
1
+ # Demo: a hidden import, end to end
2
+
3
+ This is a real transcript, not a mock-up. Every command below was run against
4
+ the app in `examples/demo_app`, which selects a reporter at runtime through
5
+ `importlib.import_module` — the pattern static analysis cannot see.
6
+
7
+ The point of the demo is the last step: `hookfix`'s output, applied to an
8
+ actual PyInstaller build, turns a crashing binary into a working one.
9
+
10
+ ## The app
11
+
12
+ ```
13
+ examples/demo_app/
14
+ ├── main.py # REPORTERS registry + importlib.import_module
15
+ └── reporters/
16
+ ├── __init__.py
17
+ ├── json_reporter.py # never named in an import statement
18
+ └── text_reporter.py
19
+ ```
20
+
21
+ `main.py` maps `"json"` to `"reporters.json_reporter"` and loads it by name.
22
+ Nothing in the source says `import reporters.json_reporter`, so PyInstaller's
23
+ module graph never contains it.
24
+
25
+ ## 1. It works from source
26
+
27
+ ```console
28
+ $ python main.py json
29
+ {
30
+ "status": "ok",
31
+ "items": 3
32
+ }
33
+ ```
34
+
35
+ ## 2. Freeze it, and it breaks
36
+
37
+ ```console
38
+ $ pyinstaller --onedir --name demo_broken --paths . main.py
39
+ $ ./dist/demo_broken/demo_broken json
40
+ Traceback (most recent call last):
41
+ ...
42
+ ModuleNotFoundError: No module named 'reporters'
43
+ [PYI-2105:ERROR] Failed to execute script 'main' due to unhandled exception!
44
+ ```
45
+
46
+ The classic symptom, and the reason this tool exists.
47
+
48
+ ## 3. hookfix finds the hidden import
49
+
50
+ ```console
51
+ $ hookfix run main.py --path . --quiet --trace-out trace.json
52
+
53
+ hookfix report
54
+ ==============
55
+
56
+ script /tmp/hookfix_demo/main.py
57
+ python 3.13.15
58
+ exit status 0
59
+ scanned 4 files, 4 static imports
60
+ observed 18 modules imported at runtime
61
+
62
+ Hidden imports (1)
63
+ -----------------
64
+ reporters.json_reporter
65
+
66
+ These modules were imported at runtime but are invisible to a static scan.
67
+ Add them to your build:
68
+
69
+ pyinstaller --hidden-import=reporters.json_reporter ...
70
+
71
+ Dynamic import sites (1)
72
+ ------------------------
73
+ main.py:25:12 importlib.import_module
74
+
75
+ These call sites are why the imports above are invisible to static analysis.
76
+ ```
77
+
78
+ ## 4. Generate the build configuration
79
+
80
+ ```console
81
+ $ hookfix fix trace.json --path . --spec
82
+ hiddenimports = [
83
+ 'reporters.json_reporter',
84
+ ]
85
+ ```
86
+
87
+ or a ready-to-use hook file:
88
+
89
+ ```console
90
+ $ hookfix fix trace.json --path . --module main -o hook-main.py
91
+ wrote hook-main.py (1 hidden imports)
92
+ ```
93
+
94
+ ## 5. The frozen binary works
95
+
96
+ ```console
97
+ $ pyinstaller --onedir --name demo_proof --paths . \
98
+ --hidden-import=reporters.json_reporter main.py
99
+ $ ./dist/demo_proof/demo_proof json
100
+ {
101
+ "status": "ok",
102
+ "items": 3
103
+ }
104
+ ```
105
+
106
+ ## What the demo also shows: the honest boundary
107
+
108
+ The app has a second reporter, `text_reporter`, that the run above never
109
+ exercised. It does not appear in the report, and the frozen binary still fails
110
+ if you ask for it:
111
+
112
+ ```console
113
+ $ ./dist/demo_proof/demo_proof text
114
+ ModuleNotFoundError: No module named 'reporters.text_reporter'
115
+ ```
116
+
117
+ That is not a bug — it is the contract. `hookfix` reports what a run actually
118
+ imported. Trace the paths you care about (the same ones your smoke tests use),
119
+ and the report tells you which call sites are dynamic so you can see what you
120
+ might still be missing.
hookfix-0.1.0/LICENSE ADDED
@@ -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.
hookfix-0.1.0/PKG-INFO ADDED
@@ -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).