简介:面向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 链走,而很多第三方包自带 hook,hook 会把整组子模块、插件目录、资源文件一股脑塞进去。全局 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=[], noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, # 目录模式下二进制留给 COLLECT name='MyTool', debug=False, strip=False, upx=False, console=False, icon='assets/app.ico', ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=False, name='MyTool', )执行顺序是Analysis分析依赖出来三个清单(纯 Python 模块、二进制、数据文件),PYZ把纯 Python 部分打包成归档,EXE生成启动器,COLLECT把二进制和数据文件铺到目录里。exclude_binaries=True是目录模式的关键,设成 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', encoding='utf-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_files=False) hiddenimports = ['mypkg.backends.native']collect_dynamic_libs只收动态库,collect_data_files的include_py_files=False是避免把.py也当数据塞进去造成重复。hook 文件命名必须严格是hook-加模块名,PyInstaller 按导入名匹配,名字写错就是静默不生效,这个坑不好查,因为没有任何报错。
判断某次缺失到底属于哪一类,看报错内容:ModuleNotFoundError补 hiddenimports,ImportError: DLL load failed查 binaries,FileNotFoundError查 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}} AppName=MyTool AppVersion=1.4.2 AppPublisher=Internal DefaultDirName={autopf}\MyTool DefaultGroupName=MyTool DisableProgramGroupPage=yes OutputDir=dist_installer OutputBaseFilename=MyTool_Setup_1.4.2 SetupIconFile=assets\app.ico Compression=lzma2/max SolidCompression=yes WizardStyle=modern ; lowest 表示装到当前用户目录,不需要管理员权限 PrivilegesRequired=lowest ArchitecturesInstallIn64BitMode=x64 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,卸载还卸不干净。PrivilegesRequired=lowest配合{autopf},实际落在当前用户的%LOCALAPPDATA%\Programs下,好处是不弹 UAC,普通账号也能装,坏处是同一台机器多用户各装一份。要装到Program Files就改成admin,代价是每次安装都要提权。
OutputBaseFilename里带版本号,方便归档和回溯,出问题时能一眼看出用户装的是哪版。Compression=lzma2/max编得慢一点但体积小,工具类软件几十兆的产物压完通常能省三四成。
[Files]的Source指向 PyInstaller 的dist\MyTool\*,recursesubdirs createallsubdirs两个 flag 必须都带,否则子目录里的_internal不会被拷进去,装上就是个空壳。ignoreversion在这里是必须的,因为 PyInstaller 产出的 exe 有时没有版本资源,不带这个 flag 安装器会跳过文件,升级时表现为“装了新版但行为没变”。
[Icons]里{group}是开始菜单组,{autodesktop}是当前用户桌面,绑到desktopicon任务上让用户勾选。{uninstallexe}是 Inno Setup 内置的卸载程序路径,加一条卸载入口省得用户去控制面板找。
4.3 版本号、图标和权限的三个细节
版本号不要写死在两个地方。.iss里写了AppVersion,Python 代码里__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}},OutputBaseFilename=MyTool_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_ok=True) logging.basicConfig( filename=os.path.join(log_dir, 'app.log'), level=logging.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。
补完之后一定要重新构建并删掉旧dist,PyInstaller 有缓存机制,不清干净的话改动可能没生效,你会以为补了没用然后继续瞎改。判断是否真生效,去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被改过,新装成了独立软件,旧版还在跑;或者构建时没带--clean,dist里混着上次的旧文件。
第三条最容易中招,尤其是删过模块之后。养成习惯:发版构建前手动删build/和dist/,或者用--clean,别信增量构建。
6. 体积和启动速度的二次压榨:能省的地方和不能碰的地方
做到能装能跑之后,往往还要面对体积问题。一个最简单的 GUI 工具打出来 60MB,发邮件都嫌大。能省的地方按收益排序:
| 手段 | 典型收益 | 风险 |
|---|---|---|
| 干净虚拟环境重建 | 20%–50% | 无,纯收益 |
| excludes 裁掉无关大包 | 10%–30% | 裁错运行时崩 |
| 升级到最新 PyInstaller | 5%–15% | 需回归测试 |
| UPX 压缩 | 30%–50% | 误报率上升 |
| strip(仅 Linux/macOS) | 5%–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'], excludes=excludes, # 其余参数略 )每次只加一两个,构建完在干净机器上把主要功能路径走一遍。一次性砍十个然后崩了,你根本不知道是哪个导致的,回退排查的成本远高于分步验证。
UPX 我的建议是默认关掉。它能把 dll 压得很小,但解压是在加载时做的,启动会慢一点,更重要的是压缩后的二进制特征变化会让一些安全软件重新判定。内部工具跑在受控环境里无所谓,对外分发就别给自己找麻烦。
最后的验证一步不能省:找一台没装过 Python、没装过你的开发工具的机器,用普通用户账号(不是管理员)装一遍,把安装路径改成带空格和中文的目录试一次。我见过不止一次在C:\Program Files\My Tool下才暴露的路径拼接问题,开发机永远复现不了。从那以后我每次发版前都强制走一遍这个清单——干净机器、普通账号、中文路径、断网启动——少一步都不踏实。希望帮到你。
本文还有配套的精品资源,点击获取