资讯动态

从虚拟环境到安装包:PyInstaller 桌面工具打包实战

发布时间:2026/10/24 2:51:26 来源:尧图企业网站定制
简介面向Python开发者的PyInstaller早期安装包0.1.4版本用于把Python脚本及其依赖打包成无需目标机器安装解释器即可运行的可执行程序适合需要分发桌面工具、命令行程序或学习冻结打包机制的技术人员。压缩包为gz格式共19个文件体积约37KB主要由txt说明与测试文本、py实现脚本、pkg-info包元数据及cfg配置等构成另含docs文档和测试用例便于了解项目结构、依赖声明与基础验证方式。资源已有589人学习虽体量轻小但保留了安装脚本、核心实现、需求与代理、冻结等测试材料可用于研究早期PyInstaller的打包思路、配置项和测试覆盖也可作为旧项目兼容排查与源码学习的参考样本。1. 一段在自己电脑上跑得很欢的脚本拷到别人机器上就报错上周看一个内部小工具200 行脚本作者本机运行得飞快拷到同事电脑上第一句就抛出ModuleNotFoundError。这和 pyinstall安装包 这个话题的起点完全一致代码在自己机器上能跑不算交付对方双击就能打开才算交付。PyInstaller 做的事并不神秘它把 Python 解释器、标准库、第三方依赖和入口脚本一起收拢成一个 exe 或者一个目录再配一层安装器让对方一路点“下一步”就装完。它不适合体积要求苛刻的发布场景也不适合重度 C 扩展加运行时动态加载的大型工程但内部工具、桌面小应用、发给非技术同事的脚本这套组合目前最省心。下面按我实际交付一个工具的先后顺序写从环境隔离讲到安装器脚本中间踩过的坑一并摆出来。2. 从 venv 到第一次构建PyInstaller 的安装与最小可跑命令2.1 打包为什么必须在干净虚拟环境里做全局环境打包最常见的翻车方式不是报错而是“能跑但包大了 80MB”。原因在于 PyInstaller 的依赖分析沿着 import 链走而很多第三方包自带 hookhook 会把整组子模块、插件目录、资源文件一股脑塞进去。全局 site-packages 里躺着几十个不相干的包只要某个依赖的 hook 顺手扫到了它们体积就失控更隐蔽的问题是解释器错位pyinstaller这个命令在 PATH 里可能绑定到另一个 Python 版本于是你明明在 3.9 环境里装的依赖它拿 3.11 去分析报一堆莫名其妙的缺失。所以第一步永远是新建虚拟环境并且用python -m PyInstaller而不是裸pyinstaller调起前者能保证用的是当前解释器。# 建环境Windows 把 source 那行换成 .venv\Scripts\activate python -m venv .venv source .venv/bin/activate # 升 pip 再装老版本 pip 解析 wheel 容易挑到源码包 python -m pip install --upgrade pip # 只装运行必需的依赖开发期依赖单独放 dev-requirements pip install -r requirements.txt pip install pyinstaller # 确认 pyinstaller 与当前解释器是同一个 python -m PyInstaller --version python -c import sys; print(sys.executable)这段的意义在于把“打包环境”固定下来。requirements.txt里尽量写死版本号尤其是那些带二进制扩展的包图像处理、数据库驱动一类不同小版本之间.pyd、.so的依赖关系够你排查一下午。如果项目里还有 pytest、black 这类只在开发时用的东西别装进这个环境它们会被扫描进去白白增加体积。还有一个常被忽略的点虚拟环境所在的路径别带中文和空格。PyInstaller 在分析阶段会拼一堆临时路径虽然新版本对空格的处理好了不少但某些第三方 hook 里手写的路径拼接仍然会在这里出问题与其事后排查不如一开始就放在D:\build\tool这种干净目录下。2.2 最小构建命令先跑通再优化第一次构建不要追求一步到位先跑出一个带控制台的目录模式产物确认能运行再往上加参数。我的习惯是先--console跑通最后再切--windowed因为窗口模式下没有任何输出通道报错就是双击闪一下没了等于自己把唯一的调试信息来源给屏蔽掉。# 目录模式 控制台用于首轮验证 python -m PyInstaller \ --noconfirm \ --clean \ --onedir \ --console \ --name MyTool \ --icon assets/app.ico \ main.py--noconfirm让它在覆盖dist/和build/时不弹交互确认适合放进脚本反复跑--clean每次清掉缓存代价是构建变慢收益是避免旧的中间产物混进新包我一般在改过依赖或 spec 之后必带--onedir产出目录启动快、便于定位缺失文件后面对比--onefile会细说--console保留黑色窗口异常和 traceback 都能看见--name决定产物名别用中文--icon只在 Windows 上对 exe 生效任务栏图标还得靠代码里另设。构建完看两个地方dist/MyTool/是最终产物build/MyTool/是中间文件。warn-MyTool.txt值得翻一遍里面记录了分析阶段找不到的可选导入大部分是无关紧要的可选依赖但如果你在业务代码里用了动态导入缺失的模块名往往就藏在这里比运行时报错早发现一步。资源文件在首轮会缺失因为 PyInstaller 默认只带 Python 模块不认你的assets目录。命令行加--add-data# Windows 用分号Linux/macOS 用冒号这是最常见的踩坑点 python -m PyInstaller --noconfirm --clean --onedir --console \ --name MyTool \ --add-data assets;assets \ --add-data config/default.json;config \ main.py参数格式是“源路径 分隔符 包内目标路径”目标路径相对于包根目录写assets表示解压后在根下有一个assets目录。Windows 上分隔符是分号类 Unix 系统是冒号写反了会得到一个名为assets;assets的奇怪文件夹或者干脆报路径不存在。这条命令在 CI 里跑要按平台区分别指望一套字符串通吃。2.3 什么时候该放下命令行改用 spec 文件参数加到五六个之后就该换 spec 了。命令行是一锤子买卖spec 是源码能进版本库、能被 review、能在里面写 Python 逻辑动态拼配置。第一次构建后目录里会生成MyTool.spec之后就可以python -m PyInstaller MyTool.spec命令行里那些参数都不再需要。# MyTool.spec —— PyInstaller 6.x 结构 a Analysis( [main.py], pathex[], binaries[], datas[(assets, assets), (config/default.json, config)], hiddenimports[], hookspath[], hooksconfig{}, runtime_hooks[], excludes[], noarchiveFalse, ) pyz PYZ(a.pure) exe EXE( pyz, a.scripts, [], exclude_binariesTrue, # 目录模式下二进制留给 COLLECT nameMyTool, debugFalse, stripFalse, upxFalse, consoleFalse, iconassets/app.ico, ) coll COLLECT( exe, a.binaries, a.datas, stripFalse, upxFalse, nameMyTool, )执行顺序是Analysis分析依赖出来三个清单纯 Python 模块、二进制、数据文件PYZ把纯 Python 部分打包成归档EXE生成启动器COLLECT把二进制和数据文件铺到目录里。exclude_binariesTrue是目录模式的关键设成 False 的话二进制会被塞进 exe目录里反而没有运行时找不到.dll。strip在 Windows 上无效那是给 macOS/Linux 剥离符号表用的。upx先关掉后面单独讲为什么我一般不用。要注意的是spec 文件本质上是一段被exec的 Python里面可以写if、可以读环境变量、可以循环生成 datas。项目里资源目录一多手写元组列表容易漏我一般这么写import os # 把 assets 下的每个子目录都加成一条 datas省得逐个手写 datas [] for name in os.listdir(assets): full os.path.join(assets, name) if os.path.isdir(full): datas.append((full, os.path.join(assets, name))) datas.append((config/default.json, config))这里的目标路径必须显式拼出来写(assets, assets)整目录拷贝也行但那样会把测试残留的临时文件一起带进安装包。资源目录里如果有.gitkeep、.psd源文件这类东西要么挪走要么逐个列出来别偷这个懒。3. spec 里的三张清单datas、hiddenimports、binaries 怎么填3.1 datas资源定位的坑不在打包在运行时datas 配对了不代表代码就能找到文件。开发时open(assets/config.json)能跑是因为当前工作目录恰好是项目根打包之后目录模式下工作目录是用户双击 exe 的位置onefile 模式下工作目录更是随机的相对路径必然失效。正确的做法是通过运行时属性定位包根。onefile 模式会把内容解压到一个临时目录路径挂在sys._MEIPASS上目录模式在 PyInstaller 6.x 之后内容放在_internal子目录sys._MEIPASS同样指向那里。所以统一走_MEIPASS回退到源码目录两边都能跑import os import sys def resource_path(relative: str) - str: 同时兼容源码运行与 PyInstaller 打包后的资源定位 base getattr(sys, _MEIPASS, None) if base is None: # 源码模式下用当前文件所在目录 base os.path.dirname(os.path.abspath(__file__)) return os.path.join(base, relative) # 用法 cfg resource_path(os.path.join(config, default.json)) with open(cfg, r, encodingutf-8) as f: content f.read()getattr给默认值 None 是为了源码模式不炸os.path.join而不是字符串拼/是为了 Windows 上路径分隔符正确。所有读取随包资源的入口都收敛到这个函数别在十几个文件里各写各的os.path.dirname(sys.argv[0])后者在快捷方式启动、从别的目录调用时都会给出不一样的结果属于典型的玄学 bug 来源。另外需要写入的配置文件不要放在资源目录里。打包后的资源目录在 onefile 模式下是临时解压的进程退出就删了用户改的配置全丢。用户级配置写到%APPDATA%或~/.config下用os.path.expanduser拼路径这个和打包无关但交付时一定会遇到。3.2 hiddenimports静态分析看不见的导入PyInstaller 靠扫描import语句建依赖图凡是运行时才决定导入谁的地方它都看不见。典型场景有三种importlib.import_module(name)按配置加载模块、插件目录扫描后批量导入、以及某些库通过__import__做的兼容层。这类问题在开发机上永远不会暴露因为本机 site-packages 齐全一打包就变成ModuleNotFoundError。排查方法很直接先单独验证模块存在python -c import mypkg.plugins.csv_exporter; print(ok)能导入说明模块在只是没被扫到补hiddenimports。手工一条条列容易漏用官方工具函数批量收集# spec 文件顶部 from PyInstaller.utils.hooks import collect_submodules, collect_data_files # 收集某个包下所有子模块忽略测试目录 hiddenimports collect_submodules(mypkg.plugins) hiddenimports [m for m in collect_submodules(mypkg) if .tests not in m] # 有些包的数据文件不在 datas 里声明就丢一起收 datas collect_data_files(mypkg)collect_submodules的实现是临时把包导入一遍再遍历子模块所以如果某个子模块在导入时有副作用连数据库、读硬件这一步会真的触发。遇到这种情况就退回手写列表或者给它加filter参数按前缀筛。判断要不要补还有一个低成本办法把构建出来的dist目录拿到一台没装 Python 的干净虚拟机里跑一遍报错信息里缺什么补什么。这比在开发机上猜准得多因为开发机上sys.path里什么都有本地跑通不代表包里齐全。3.3 binaries 与 hooks二进制依赖由专门机制处理纯 Python 包改 datas 和 hiddenimports 基本够用带 C 扩展的包就麻烦一些。.pyd、.so、.dll这类文件走 binaries 清单PyInstaller 对主流包数值计算、图像处理、GUI 框架有内置 hook正常情况下不用手动干预装好pyinstaller-hooks-contrib就行它跟着 PyInstaller 一起装版本对不上时用pip install -U pyinstaller-hooks-contrib单独升。hook 的本质是hook-模块名.py里面声明额外依赖、数据文件和二进制。自己写 hook 的场景不多但有一种很常见内部包用了某种资源加载机制hook 没覆盖到。写法是放一个hook-mypkg.py到项目根spec 里指定hookspath[.]# hook-mypkg.py —— 告诉 PyInstaller 这个包还需要什么 from PyInstaller.utils.hooks import collect_dynamic_libs, collect_data_files binaries collect_dynamic_libs(mypkg) datas collect_data_files(mypkg, include_py_filesFalse) hiddenimports [mypkg.backends.native]collect_dynamic_libs只收动态库collect_data_files的include_py_filesFalse是避免把.py也当数据塞进去造成重复。hook 文件命名必须严格是hook-加模块名PyInstaller 按导入名匹配名字写错就是静默不生效这个坑不好查因为没有任何报错。判断某次缺失到底属于哪一类看报错内容ModuleNotFoundError补 hiddenimportsImportError: DLL load failed查 binariesFileNotFoundError查 datas。三类问题症状不同处理路径也不同混在一起猜只会浪费时间。4. 从目录产物到安装包Inno Setup 脚本怎么写4.1 为什么最终产物是目录加安装器而不是一个 exe--onefile看起来很美一个 exe 发给别人就完事。实际交付过几轮之后我基本不用它了原因是三个每次启动都要把内容解压到%TEMP%下的_MEIxxxx目录冷启动能慢到三到五秒用户会以为程序卡死自解压行为在杀毒软件眼里和某些恶意程序的特征高度重合误报率明显高于目录模式没法做增量更新改一行代码就得整个包重发。目录模式配安装器的组合把这三点都解决了安装时解压一次之后启动就是普通程序加载安装器可以写注册表、建快捷方式、带卸载入口用户感知上是正经软件更新时替换文件即可配合版本号还能做覆盖安装。安装器在 Windows 上我一般用 Inno Setup脚本语法直白生成单文件安装程序中文界面支持也完整。4.2 .iss 脚本的六个关键段下面是一份能直接用的小工具安装脚本把明显的占位符换掉就行[Setup] ; AppId 是升级和卸载的识别依据同一个软件必须始终一致换了这个就变成装两份 AppId{{A1B2C3D4-1111-2222-3333-444455556666}} AppNameMyTool AppVersion1.4.2 AppPublisherInternal DefaultDirName{autopf}\MyTool DefaultGroupNameMyTool DisableProgramGroupPageyes OutputDirdist_installer OutputBaseFilenameMyTool_Setup_1.4.2 SetupIconFileassets\app.ico Compressionlzma2/max SolidCompressionyes WizardStylemodern ; lowest 表示装到当前用户目录不需要管理员权限 PrivilegesRequiredlowest ArchitecturesInstallIn64BitModex64 UninstallDisplayIcon{app}\MyTool.exe [Tasks] Name: desktopicon; Description: 创建桌面快捷方式; GroupDescription: 附加任务: [Files] Source: dist\MyTool\*; DestDir: {app}; \ Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: {group}\MyTool; Filename: {app}\MyTool.exe Name: {group}\卸载 MyTool; Filename: {uninstallexe} Name: {autodesktop}\MyTool; Filename: {app}\MyTool.exe; Tasks: desktopicon [Run] Filename: {app}\MyTool.exe; Description: 立即运行 MyTool; \ Flags: nowait postinstall skipifsilent逐段说清楚。[Setup]里AppId必须用固定值它在注册表里标识这个软件你改了它用户装新版本时会发现控制面板里出现两个 MyTool卸载还卸不干净。PrivilegesRequiredlowest配合{autopf}实际落在当前用户的%LOCALAPPDATA%\Programs下好处是不弹 UAC普通账号也能装坏处是同一台机器多用户各装一份。要装到Program Files就改成admin代价是每次安装都要提权。OutputBaseFilename里带版本号方便归档和回溯出问题时能一眼看出用户装的是哪版。Compressionlzma2/max编得慢一点但体积小工具类软件几十兆的产物压完通常能省三四成。[Files]的Source指向 PyInstaller 的dist\MyTool\*recursesubdirs createallsubdirs两个 flag 必须都带否则子目录里的_internal不会被拷进去装上就是个空壳。ignoreversion在这里是必须的因为 PyInstaller 产出的 exe 有时没有版本资源不带这个 flag 安装器会跳过文件升级时表现为“装了新版但行为没变”。[Icons]里{group}是开始菜单组{autodesktop}是当前用户桌面绑到desktopicon任务上让用户勾选。{uninstallexe}是 Inno Setup 内置的卸载程序路径加一条卸载入口省得用户去控制面板找。4.3 版本号、图标和权限的三个细节版本号不要写死在两个地方。.iss里写了AppVersionPython 代码里__version__又写一份发版时改漏一处用户看到的版本就是错的。我的做法是版本号只在 Python 包里维护一份构建脚本读出来传给安装器# 构建脚本里从模块读版本替换 iss 模板 VER$(python -c import mytool; print(mytool.__version__)) sed s/{{VERSION}}/${VER}/g installer/setup.iss.tpl installer/setup.iss iscc installer/setup.iss模板里写AppVersion{{VERSION}}OutputBaseFilenameMyTool_Setup_{{VERSION}}。iscc是 Inno Setup 的命令行编译器装完在安装目录下加进 PATH 就能在 CI 里跑。图标有两个位置要区分SetupIconFile是安装程序自己窗口里的图标exe 的图标来自 PyInstaller 的--icon两者不共享。想统一就都指向同一个.ico尺寸至少包含 256×256 那一档否则资源管理器大图标视图下会糊。代码签名这块没有证书的话用户第一次运行必然看到 SmartScreen 警告。这不是打包问题是分发问题能做的只有让安装器体积正常、行为规矩减少被判定为可疑的概率。有内部证书的话在[Setup]里配SignTool但证书申请流程不在这篇讨论范围内。5. 避坑与排查在客户机器上才暴露的五类问题5.1 现象双击没反应任务管理器里闪一下就没了原因几乎总是窗口模式吞掉了异常。--windowed之后程序没有控制台任何未捕获异常只写进 stderr而 stderr 在 Windows GUI 程序里是空的。解决方式分两步先出一版带--console的临时构建让用户跑一遍把报错截图发过来定位到具体模块再修。长期方案是在入口处装全局异常钩子把 traceback 写到用户目录下的日志文件import logging import os import sys import traceback log_dir os.path.join(os.path.expanduser(~), .mytool, logs) os.makedirs(log_dir, exist_okTrue) logging.basicConfig( filenameos.path.join(log_dir, app.log), levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, ) def excepthook(exc_type, exc_value, exc_tb): logging.error(未捕获异常\n%s, .join(traceback.format_exception(exc_type, exc_value, exc_tb))) sys.__excepthook__(exc_type, exc_value, exc_tb) sys.excepthook excepthook日志目录写在用户目录而不是程序目录因为程序装在Program Files下普通账号没有写权限往那里写日志会二次崩溃报错还变成权限问题更难查。5.2 现象开发机跑得好打包后报 ModuleNotFoundError原因在 3.2 已经讲过动态导入没被静态分析捕获。解决就是补hiddenimports或者用collect_submodules。补完之后一定要重新构建并删掉旧distPyInstaller 有缓存机制不清干净的话改动可能没生效你会以为补了没用然后继续瞎改。判断是否真生效去build/MyTool/Analysis-00.toc里搜模块名能搜到才算进包了。5.3 现象杀毒软件报毒或者 exe 直接被删原因通常是 onefile 的自解压行为加上无签名。某些安全软件对“释放临时文件并执行”的行为一律拦截和代码本身无关。解决顺序先换--onedir单独这一条能消掉大部分误报再考虑升级到带安装器的分发形态安装程序的行为模式比自解压 exe 常规得多最后才是签名。别一上来就加 UPX 压缩压缩会改变二进制特征误报率反而上升得不偿失。5.4 现象资源文件在开发时能读装完读不到原因有两个一是相对路径依赖工作目录二是--add-data写错方向。前者用第 3 章的resource_path解决后者表现为包内根本没有那个目录。排查方法是解压安装后的目录看_internal下有没有你期望的文件夹。PyInstaller 6.x 把内容都放进_internal很多旧教程还停留在文件直接铺在根目录的年代照着做会找错位置。如果目录存在但代码读不到就在resource_path里打一行日志把 base 打出来路径一对就明白了。5.5 现象升级安装后行为没变像装了旧版三个可能。[Files]段漏了ignoreversion安装器认为目标文件更新因而不覆盖AppId被改过新装成了独立软件旧版还在跑或者构建时没带--cleandist里混着上次的旧文件。第三条最容易中招尤其是删过模块之后。养成习惯发版构建前手动删build/和dist/或者用--clean别信增量构建。6. 体积和启动速度的二次压榨能省的地方和不能碰的地方做到能装能跑之后往往还要面对体积问题。一个最简单的 GUI 工具打出来 60MB发邮件都嫌大。能省的地方按收益排序手段典型收益风险干净虚拟环境重建20%–50%无纯收益excludes 裁掉无关大包10%–30%裁错运行时崩升级到最新 PyInstaller5%–15%需回归测试UPX 压缩30%–50%误报率上升strip仅 Linux/macOS5%–10%无excludes 是最有效也最容易砍错的一项。它按模块名排除PyInstaller 不会再分析被排除模块的依赖所以体积掉得明显。代价是有些包是被间接依赖进来的你以为用不到实际某个功能分支里用到了直到用户点了那个按钮才崩。# spec 中的 excludes按需增删先小步试 excludes [ tkinter, # 不用 Tk 就砍标准库里它不小 unittest, pydoc, doctest, test, numpy, pandas, matplotlib, # 常见误扫的大包 PyQt5, PySide2, PySide6, # 换 GUI 框架后残留的 IPython, jupyter, ] a Analysis( [main.py], excludesexcludes, # 其余参数略 )每次只加一两个构建完在干净机器上把主要功能路径走一遍。一次性砍十个然后崩了你根本不知道是哪个导致的回退排查的成本远高于分步验证。UPX 我的建议是默认关掉。它能把 dll 压得很小但解压是在加载时做的启动会慢一点更重要的是压缩后的二进制特征变化会让一些安全软件重新判定。内部工具跑在受控环境里无所谓对外分发就别给自己找麻烦。最后的验证一步不能省找一台没装过 Python、没装过你的开发工具的机器用普通用户账号不是管理员装一遍把安装路径改成带空格和中文的目录试一次。我见过不止一次在C:\Program Files\My Tool下才暴露的路径拼接问题开发机永远复现不了。从那以后我每次发版前都强制走一遍这个清单——干净机器、普通账号、中文路径、断网启动——少一步都不踏实。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑