Metadata-Version: 2.5
Name: hookfix
Version: 0.1.0
Summary: Find the hidden imports that break your frozen Python app.
Project-URL: Homepage, https://github.com/sqmyou/hookfix
Project-URL: Repository, https://github.com/sqmyou/hookfix
Project-URL: Changelog, https://github.com/sqmyou/hookfix/blob/main/CHANGELOG.md
Author-email: sqm <sqmyou@users.noreply.github.com>
License-Expression: MIT
License-File: LICENSE
Keywords: bundling,freeze,hidden-imports,nuitka,packaging,pyinstaller
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# hookfix

**Find the hidden imports that break your frozen Python app.**

[![CI](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml/badge.svg)](https://github.com/sqmyou/hookfix/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/hookfix.svg)](https://pypi.org/project/hookfix/)
[![Python versions](https://img.shields.io/pypi/pyversions/hookfix.svg)](https://pypi.org/project/hookfix/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

---

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.

```console
$ 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](https://peps.python.org/pep-0578/)) listening for the `import`
event. It does not work, and the reason is subtle enough to be worth stating:

```python
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

```console
pip install hookfix
```

`hookfix` has no runtime dependencies and needs Python 3.10 or newer.

## Usage

### `hookfix run` — trace a program

```console
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:

```console
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

```console
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

```console
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

1. **Trace.** The CLI spawns a child process (`python -m hookfix._bootstrap`)
   that installs a recording meta path finder and then runs your script with
   `runpy`, mimicking a plain `python script.py` invocation. Every resolved
   module is logged as `name<TAB>origin`.

2. **Scan.** `hookfix` walks the source tree with `ast` and collects every
   `import` statement, plus the location of every dynamic import call site.

3. **Diff.** The runtime modules minus the statically visible ones are the
   hidden imports. Standard-library modules are split out (a freezer bundles
   those anyway) and import-machinery internals are filtered as noise.

4. **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

```console
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](LICENSE).
