hookfix
Find the hidden imports that break your frozen Python app.
You build your app, it works perfectly. You freeze it with PyInstaller or Nuitka, ship the binary, and it dies on a user's machine with:
ModuleNotFoundError: No module named 'your_plugin'
The module is installed. It is in your source tree. It just never appears in an
import statement that a static analyser can see — it is pulled in by
importlib.import_module, a plugin registry, an entry point, or a
__getattr__ on a package. Freezers trace imports by reading source, so they
miss it, and you get to add --hidden-import flags one error report at a time.
hookfix takes the other route. It runs your program, watches every module
the interpreter actually imports, subtracts the imports that are visible to a
static scan, and hands you the difference — the hidden imports, ready to paste
into your build.
$ hookfix run app.py --path .
hookfix report
==============
script /home/you/app/app.py
python 3.12.4
exit status 0
scanned 12 files, 34 static imports
observed 87 modules imported at runtime
Hidden imports (2)
------------------
plugins.report
yaml
These modules were imported at runtime but are invisible to a static scan.
Add them to your build:
pyinstaller --hidden-import=plugins.report --hidden-import=yaml ...
Or generate a hook file for all of them at once:
hookfix fix
Dynamic import sites (2)
------------------------
app.py:41:12 importlib.import_module
plugins/load.py:9:5 __import__
These call sites are why the imports above are invisible to static analysis.
Why not just use audit hooks?
The obvious way to watch imports is a CPython audit hook
(PEP 578) listening for the import
event. It does not work, and the reason is subtle enough to be worth stating:
import importlib
importlib.import_module("plugins.report") # does NOT raise the import event
__import__("plugins.report") # raises it
import plugins.report # raises it
importlib.import_module calls the internal _gcd_import directly and never
raises the audit event. Since importlib.import_module is the standard way to
load a module dynamically, an audit-hook-based tracer has a blind spot over
precisely the case it is meant to catch.
hookfix installs an importlib.abc.MetaPathFinder at the front of
sys.meta_path instead. Every module resolution that goes through the import
system passes through sys.meta_path, whatever triggered it — so statements,
importlib, __import__ and lazy loaders are all observed.
Installation
pip install hookfix
hookfix has no runtime dependencies and needs Python 3.10 or newer.
Usage
hookfix run — trace a program
hookfix run app.py --path .
Runs app.py under the tracer and prints the report above. --path sets the
directory to scan statically (default: the script's directory). Arguments after
-- are passed through to your program:
hookfix run app.py -- --config prod.yaml
Useful flags:
| Flag | Meaning |
|---|---|
--path DIR |
directory to scan statically (default: the script's directory) |
--exclude NAME |
skip a directory or module while scanning (repeatable) |
--trace-out FILE |
save the trace as JSON for later use |
--json |
print the report as JSON instead of text |
--quiet |
suppress the traced program's own output |
hookfix diff — compare a saved trace against source
hookfix run app.py --trace-out trace.json --quiet
hookfix diff trace.json --path .
Re-runs the comparison without executing the program again. Handy in CI, where you want to trace once and check the result from a different step.
hookfix fix — generate build configuration
hookfix fix trace.json --module app # -> hook-app.py
hookfix fix trace.json --module app -o out/ # -> out/hook-app.py
hookfix fix trace.json --spec # -> hiddenimports = [...] snippet
--module app writes a PyInstaller hook file, the reusable form of
--hidden-import. --spec prints just the hiddenimports = [...] list to drop
into an existing .spec file.
What it does and does not do
It reports what one run actually imported. That is the honest boundary of any runtime tool. If a code path never executed — a plugin for a mode you did not exercise, a platform-specific branch — its imports will not appear.
So: run hookfix against the widest set of inputs you can, ideally the same
ones your smoke tests use. The output tells you which call sites are dynamic, so
you can see what you might have missed. Treat the generated hook file as a
starting point to review, not as a finished artefact — the header says as much.
It also cannot tell you about data files, native libraries, or metadata that a freezer might drop. It is specifically about imports.
How it works
-
Trace. The CLI spawns a child process (
python -m hookfix._bootstrap) that installs a recording meta path finder and then runs your script withrunpy, mimicking a plainpython script.pyinvocation. Every resolved module is logged asname<TAB>origin. -
Scan.
hookfixwalks the source tree withast, collects everyimportstatement, and records the location of every dynamic import call site. From the entry point it then walks the import graph to work out which modules a freezer will actually bundle. The two sets differ: a file that nothing imports is still scanned but is never bundled, so comparing against every import in the tree would hide real gaps. -
Diff. The runtime modules minus the reachable ones are the hidden imports. Standard-library modules are split out (a freezer bundles those anyway) and import-machinery internals are filtered as noise.
-
Report. The remainder is printed, or rendered as a hook file or spec snippet.
Everything is serialisable: --trace-out writes a versioned JSON document, and
diff/fix read it back, so a trace taken on one machine can be inspected on
another.
Development
git clone https://github.com/sqmyou/hookfix
cd hookfix
python -m pip install -e ".[dev]"
python -m pytest
The test suite includes a fixture (tests/fixtures/dynamic_app) that loads a
plugin through importlib.import_module — the case that motivated the whole
tool. python -m ruff check src tests and python -m mypy must both pass.
License
MIT. See LICENSE.
Metadata
Release files for hookfix 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| hookfix-0.1.1.tar.gz | 27.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| hookfix-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 49.8 kB
Release files / hookfix-0.1.1.tar.gz
| Download URL | hookfix-0.1.1.tar.gz |
|---|---|
| Size | 27.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
46e995c49217dbb14039aa7679a60a993eb8b1a832ff77584be6e0e134603138
|
|
BLAKE2b-256 checksum How to use checksums |
97996b61672ca6d2929bc07edfc27ae47e0205615d69da6065adedc644fc00f6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency logRelease files / hookfix-0.1.1-py3-none-any.whl
| Download URL | hookfix-0.1.1-py3-none-any.whl |
|---|---|
| Size | 22.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
892c0da8fccaf3878c62bf467fc06f3051f094f91f76c20fcaaef1e15c45c6e9
|
|
BLAKE2b-256 checksum How to use checksums |
6c205872cb1b7801278945cf7caffa0092a15138bcd9b09b28b659225d4aad63
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 7, 2026.
Transparency log