Build1 publisher2 min readPublished
A function-scoped import cost one PyInstaller build its version number
PyInstaller builds its dependency graph by reading source text without running it, so an import written inside a function body can be left out of the bundle and only surface when a user opens that feature.
The Engineer · Build desk
What happened
- PyInstaller's documentation calls a module its scan cannot see a hidden import, and the pattern covers import strings assembled from runtime variables as well as pkgutil sweeps of a directory.
- One missing module produced no error at all: a fallback returned the default version string 0.0.0, so the executable launched normally and displayed the wrong version to the user.
- The project now lists every new core/*.py module explicitly in the hidden array of build_app.py, even when a top-level import chain would probably already pull the module in.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint A green test run against the source tree carries no information about the bundle, because the graph that decides what ships is built from text the tests never influence.
- exposure Any absent module whose absence is swallowed by a default-value fallback ships as wrong data, and a smoke test that only checks the process starts will sign it off.
- cost Under the project's rule, every new module in core/ costs a maintained line in the build config, paid by whoever adds the module rather than by whoever debugs the frozen build.
The analysis phase reads the entry point's source, walks `import` and `from ... import ...` statements, and builds the dependency graph from that text; none of the project's code runs while it happens [2]. That constraint explains the dynamic cases exactly. `importlib.import_module(f"core.{plugin_name}")` has no value to read at scan time, so the module never enters the graph [4]. A `pkgutil.iter_modules()` sweep of a directory names nothing literally either [5]. Neither can be resolved without executing the program, so PyInstaller does not try. A third-party package that loads its own submodules fails the same way when the hook written for it does not enumerate every one [6].
The in-house misses are a different shape. `core/thumbnail_utils.py` and `core/site_paths.py` are literal names. According to the post, the only reference to each was a `from core.xxx import yyy` written inside a function body, and each threw `ModuleNotFoundError` only in the frozen Windows or Mac build [7]. For the text scan to be the whole explanation there, it has to treat a function-scoped import in an already-visited module differently from a module-level one. The evidence offered is repeated incidents in one project's `build_app.py` [11], not a reading of the analyser. If your own builds pick those up, that difference is the first thing to check before adopting the rule.
Timing is what makes this class expensive. The app runs correctly from source, and the frozen executable raises its `ModuleNotFoundError` three clicks deep, in a code path nobody happened to exercise right after the build [8]. Every function-scoped import is a branch that has to be reached in the packaged artifact before anyone can say the packaging worked.
The version incident is worse than a crash. A module holding the app's version string was pulled in by a function-scoped `from version import VERSION` inside `core/updater.py`, and it was never added to the hidden-import list [9]. A fallback path caught the absence and quietly returned a cached or default value, `"0.0.0"` [10]. "A loud ModuleNotFoundError is, in a way, the easier failure to catch," the post's author wrote [15].
Declaring is cheap. Pass `--hidden-import <module_name>` on the command line, once per module, or list the modules in the `hiddenimports` argument of the `Analysis` object inside the `.spec` file; both supply the same thing to the analyser [13]. The stated reasoning for declaring more than you strictly need is to "don't over-trust the completeness of static analysis, and declare anything you suspect might be ambiguous" [12], on the grounds that "a little redundancy in the build configuration is far cheaper than an incident that only surfaces after a build ships" [14].
What to watch
- Whether PyInstaller's analyser is documented to descend into function bodies of already-visited modules, which would settle what actually dropped core/site_paths.py.
- Whether the project adds a build-time check that diffs core/*.py against the hidden array, removing the manual step the rule now depends on.
- Whether the updater's fallback logs the missing version module, which decides if a shipped 0.0.0 is detectable without a user report.