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.
- hookfix-0.1.0/.github/workflows/ci.yml +47 -0
- hookfix-0.1.0/.github/workflows/publish.yml +48 -0
- hookfix-0.1.0/.gitignore +38 -0
- hookfix-0.1.0/AGENTS.md +84 -0
- hookfix-0.1.0/CHANGELOG.md +31 -0
- hookfix-0.1.0/DEMO.md +120 -0
- hookfix-0.1.0/LICENSE +21 -0
- hookfix-0.1.0/PKG-INFO +225 -0
- hookfix-0.1.0/README.md +193 -0
- hookfix-0.1.0/examples/demo_app/main.py +36 -0
- hookfix-0.1.0/examples/demo_app/reporters/__init__.py +1 -0
- hookfix-0.1.0/examples/demo_app/reporters/json_reporter.py +7 -0
- hookfix-0.1.0/examples/demo_app/reporters/text_reporter.py +5 -0
- hookfix-0.1.0/pyproject.toml +107 -0
- hookfix-0.1.0/src/hookfix/__init__.py +15 -0
- hookfix-0.1.0/src/hookfix/__main__.py +6 -0
- hookfix-0.1.0/src/hookfix/_bootstrap.py +76 -0
- hookfix-0.1.0/src/hookfix/cli.py +267 -0
- hookfix-0.1.0/src/hookfix/differ.py +66 -0
- hookfix-0.1.0/src/hookfix/errors.py +19 -0
- hookfix-0.1.0/src/hookfix/model.py +135 -0
- hookfix-0.1.0/src/hookfix/py.typed +0 -0
- hookfix-0.1.0/src/hookfix/report.py +89 -0
- hookfix-0.1.0/src/hookfix/scanner.py +164 -0
- hookfix-0.1.0/src/hookfix/spec_writer.py +41 -0
- hookfix-0.1.0/src/hookfix/tracer.py +125 -0
- hookfix-0.1.0/tests/__init__.py +1 -0
- hookfix-0.1.0/tests/conftest.py +9 -0
- hookfix-0.1.0/tests/fixtures/dynamic_app/app.py +34 -0
- hookfix-0.1.0/tests/fixtures/dynamic_app/plugins/__init__.py +1 -0
- hookfix-0.1.0/tests/fixtures/dynamic_app/plugins/archive.py +9 -0
- hookfix-0.1.0/tests/fixtures/dynamic_app/plugins/report.py +5 -0
- hookfix-0.1.0/tests/test_cli.py +147 -0
- hookfix-0.1.0/tests/test_differ.py +46 -0
- hookfix-0.1.0/tests/test_scanner.py +62 -0
- hookfix-0.1.0/tests/test_spec_writer.py +34 -0
- 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
|
hookfix-0.1.0/.gitignore
ADDED
|
@@ -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
|
hookfix-0.1.0/AGENTS.md
ADDED
|
@@ -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
|
+
[](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/hookfix/)
|
|
39
|
+
[](https://pypi.org/project/hookfix/)
|
|
40
|
+
[](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).
|