# PyInstaller spec for Dev Assist Chatbot. # # Build (from the repo root, in an environment with the CUDA 13.x toolkit # installed - the toolkit is only needed at BUILD time, to copy its runtime # DLLs into the bundle; end users only need an NVIDIA driver): # # uv run pyinstaller packaging/build.spec --noconfirm # # Output lands in dist/DevAssistChatbot/. That whole folder is the app - # onedir rather than onefile, since unpacking a multi-GB bundle to a temp # dir on every launch would make startup unusable. import os from pathlib import Path from PyInstaller.utils.hooks import collect_submodules PROJECT_ROOT = Path(SPECPATH).parent # noqa: F821 - injected by PyInstaller VENV_SITE_PACKAGES = PROJECT_ROOT / ".venv" / "Lib" / "site-packages" LLAMA_CPP_LIB = VENV_SITE_PACKAGES / "llama_cpp" / "lib" if not LLAMA_CPP_LIB.is_dir(): raise SystemExit( f"Expected llama_cpp's compiled libs at {LLAMA_CPP_LIB} - run this via " "'uv run pyinstaller ...' from the repo root so the project venv is used." ) CUDA_PATH = os.environ.get("CUDA_PATH") if not CUDA_PATH: raise SystemExit( "CUDA_PATH is not set. Building requires the CUDA toolkit that " "llama-cpp-python was compiled against, so its runtime DLLs can be " "copied into the bundle (end users won't need the toolkit installed)." ) CUDA_BIN = Path(CUDA_PATH) / "bin" / "x64" # llama.cpp's ggml backend (ggml-cuda.dll etc.) is loaded dynamically at # runtime, not linked at import time, so PyInstaller's static analysis can't # discover it - every DLL in llama_cpp/lib/ has to be added explicitly. They # all go into the same output folder so Windows' DLL search (which checks a # loaded DLL's own directory for its dependencies) can resolve cublas from # next to ggml-cuda.dll. CUDA_RUNTIME_DLLS = [ "cudart64_13.dll", "cublas64_13.dll", "cublasLt64_13.dll", "nvrtc64_130_0.dll", "nvrtc-builtins64_134.dll", ] binaries = [(str(dll), "llama_cpp/lib") for dll in LLAMA_CPP_LIB.glob("*.dll")] for name in CUDA_RUNTIME_DLLS: dll_path = CUDA_BIN / name if not dll_path.is_file(): raise SystemExit(f"Expected CUDA runtime DLL not found: {dll_path}") binaries.append((str(dll_path), "llama_cpp/lib")) # ddgs picks its search backend (duckduckgo, bing, ...) via importlib at # runtime rather than a static import, so PyInstaller won't find those # engine modules on its own either. hidden_imports = collect_submodules("ddgs") + ["pynvml"] a = Analysis( # noqa: F821 [str(PROJECT_ROOT / "main.py")], pathex=[str(PROJECT_ROOT)], binaries=binaries, datas=[], hiddenimports=hidden_imports, hookspath=[], excludes=[], noarchive=False, ) # PyInstaller's own dependency walker separately discovers cublas64_13.dll # and cublasLt64_13.dll (ggml-cuda.dll imports them directly) and stages its # own copy at the bundle root, on top of the copy the `binaries` list above # already places in llama_cpp/lib/ (which is where they need to be, next to # ggml-cuda.dll, for ITS dependency lookup to find them - see the comment # above). The root copy is pure duplication: nothing loads cublas from # there. Drop it - it's ~547MB. _DUPLICATED_AT_ROOT = {"cublas64_13.dll", "cublasLt64_13.dll"} a.binaries = [ entry for entry in a.binaries if not (Path(entry[0]).name in _DUPLICATED_AT_ROOT and Path(entry[0]).parent == Path(".")) ] # Qt/PyQt6 features this app doesn't use, bundled defensively by PyQt6's # PyInstaller hook. Confirmed safe to drop: Qt6Widgets/Qt6Gui/Qt6Core don't # link any of these at the DLL level (only load them on demand), no # PyQt6.QtNetwork/QtSvg/QtPdf Python module is bundled or imported anywhere # in this app, no QTranslator is ever installed, and app.setStyle("Fusion") # in gui.py forces a style that's built into Qt6Widgets itself rather than # loaded from the styles/ plugin. _UNUSED_QT_BASENAMES = { "opengl32sw.dll", # software OpenGL fallback; no QOpenGLWidget/3D used "qt6pdf.dll", "qt6network.dll", "qt6svg.dll", "qsvg.dll", # imageformats SVG plugin "qsvgicon.dll", # iconengines SVG plugin "qpdf.dll", # imageformats PDF plugin (depends on the Qt6Pdf.dll dropped above) "qmodernwindowsstyle.dll", # styles plugin; app always forces Fusion } def _is_unused_qt_binary(dest: str) -> bool: return Path(dest).name.lower() in _UNUSED_QT_BASENAMES a.binaries = [entry for entry in a.binaries if not _is_unused_qt_binary(entry[0])] # Qt's .qm translation files ship as data, not binaries - this app never # installs a QTranslator, so none of the 96 bundled languages are ever used. a.datas = [entry for entry in a.datas if not str(entry[0]).lower().endswith(".qm")] pyz = PYZ(a.pure) # noqa: F821 exe = EXE( # noqa: F821 pyz, a.scripts, [], exclude_binaries=True, name="DevAssistChatbot", console=False, ) coll = COLLECT( # noqa: F821 exe, a.binaries, a.zipfiles, a.datas, # UPX would try (slowly, and sometimes badly) to compress the ~650MB of # CUDA runtime DLLs; skip it. upx=False, name="DevAssistChatbot", )