PyInstaller works out what to bundle by following imports from your entry script. A packaged Streamlit app breaks that, because the entry script does not import the app. It runs it:
sys.argv = ["streamlit", "run", str(base_dir / "app.py"), ...]
raise SystemExit(stcli.main())
app.py ships as a data file and is executed by path at runtime. PyInstaller’s analysis never opens it, so every module of your own that only app.py imports is invisible to it. The build succeeds. The executable starts, and then the app fails with ModuleNotFoundError as soon as Streamlit runs the script.
The fix is to declare them as hidden imports. The question is how to stop that list going stale.
Derive it. PyInstaller ships a helper that walks a package for you, and it is probably already imported in your spec for third-party packages:
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = collect_submodules("mybot")
On my project that returned exactly the same sixteen modules as the hand-written list it replaced, and a build confirmed all sixteen made the bundle. It is what the spec uses now.
Or keep the explicit list, and test it. An explicit list has a real advantage — it is reviewable, and adding a module to the bundle becomes a visible change. But a hand-maintained list with nothing checking it will eventually be wrong. Derive the expected set from the filesystem in a test instead:
def test_every_module_in_the_package_is_a_declared_hidden_import(self):
spec = (ROOT / "mybot.spec").read_text(encoding="utf-8")
for module in sorted(p.stem for p in (ROOT / "mybot").glob("*.py")):
if module == "__init__":
continue
self.assertIn(f'"mybot.{module}"', spec, f"mybot.{module} is missing from hiddenimports")
That test has caught drift once: a module added in a later release was never added to the spec. That time the build would have been fine, because PyInstaller also follows the imports of the hidden imports, and a module already on the list imported the new one. A module that only app.py imports gets no such rescue.
Either works. What does not work is the option most projects end up with by default — a list typed out once, when the app had five modules, and never checked again.