简介:PyInstaller-3.2.1 是一份定位于 Python 程序分发与打包实践的旧版工具资源,适合需要把脚本交付给无 Python 环境用户、或希望研究 PyInstaller 内部实现与构建流程的开发者,也可用于教学演示和旧版本兼容性排查,尤其适合 Windows 平台上的 .exe 交付场景。它通过分析脚本依赖,将标准库、第三方库、C 扩展、DLL 与数据文件一并收集,支持单文件、多文件发布及跨平台构建,可复现 3.x 时期项目的打包环境。压缩包为 zip,共 790 个文件、约 3.01MB,以 549 个 .py 源码为主体,同时包含 .c/.h 扩展源码、.spec 构建配置、.toc 清单、rst/txt 文档及少量 exe、dll、pyd 等二进制与示例文件,便于按模块查阅。已有 602 人学习下载。读者可从中了解依赖分析、隐式导入处理、资源收集和调试日志等关键环节,并借助自带示例与配置理解 onefile/onedir 产物结构,为排错和旧项目维护提供参考。
1. 一个只认旧版本的维护项目,把我推回了 PyInstaller 3.2.1
接手一个维护期的桌面工具时,运行环境被锁死在 Python 3.6,机器上连编译链都是老的,新版本装上去直接报错。能对得上的打包工具,只剩 PyInstaller 3.2.1。它干的事很朴素:把.py连同解释器、依赖库、数据文件一起封成一个不依赖目标机 Python 环境的可执行文件。难的不是打出来,是打出来之后在别人机器上还能跑。这篇把这套老版本打包工具的链路、命令行参数、spec 改法和踩过的坑摊开讲,适合手上有 Python 3.6 老项目、又必须交付 exe 的人。
2. 拆开 bootloader 与 _MEI 目录:PyInstaller 3.2.1 的打包链路
很多人对打包工具有个误解,以为 PyInstaller 是把 Python 代码"编译"成机器码。不是。它做的是搬运:把解释器、字节码、扩展模块、数据文件按依赖关系收集起来,装进一个容器,再配一个用 C 写的小启动器。理解这一点,后面所有的报错就都能对上号——打包产物跑不起来,九成是"该搬的东西没搬进去",或者"搬进去了但运行时找不到"。
3.2.1 这个版本的处理流程分三段,依次是 Analysis、PYZ、EXE,三段之间靠 TOC 表传递结果。下面按顺序拆。
2.1 Analysis:依赖是静态扫出来的,不是跑出来的
Analysis 阶段拿到你的入口脚本后,调用内部依赖图工具从import语句出发做静态分析,把入口能触及的模块一条条列出来。注意关键词是"静态"——它不执行你的代码,只读语法树。这意味着任何在运行时才决定的导入,它都看不见:__import__(某个变量)、importlib.import_module(字符串)、eval拼出来的 import、靠扫描目录发现的插件、pkg_resources的入口点加载,统统逃过扫描。这是后面所有ModuleNotFoundError的总根源,记住这条,补依赖时就不用瞎猜。
扫描结果落成四张 TOC 表,每行是(name, path, typecode)三元组,例如('yaml', '/venv/lib/python3.6/site-packages/yaml/__init__.py', 'PYMODULE')。四张表分别是a.scripts(入口脚本)、a.pure(纯 Python 模块)、a.binaries(.pyd/.so这类二进制扩展和动态库)、a.datas(配置文件、模板、字体等非代码文件)。同时它会去 PyInstaller 自带的 hooks 目录和hookspath里找与模块同名的 hook 文件,hook 里可以往这几张表里补东西,这是官方给常见第三方库打的补丁。
分析跑完,build/<name>/下会生成Analysis-00.toc和一个极有价值的文件warn-<name>.txt。后者列出了"被 import 了但没找到"的模块清单,判断到底缺什么,第一眼看的就是它,比在打包产物上盲试快得多。
2.2 PYZ 与 EXE:bootloader 干的是解压和接管导入
a.pure里的纯 Python 模块会被编译成.pyc,压进一个叫 PYZ 的归档。这一步是性能优化,把成百上千个小文件收成一个,减少运行时的文件系统调用。接着进入 EXE 阶段:把预编译好的 bootloader(一个独立的 C 可执行体,与你的业务代码无关)和装着所有料包的 CArchive 拼接,得到最终产物。
运行时的动作序列是这样的:bootloader 启动,判定自己是单文件还是目录模式,确定一个工作目录,把料包解开(单文件模式解到%TEMP%下的_MEIxxxxxx随机目录),把这个目录塞进sys.path,注册一个从归档里读模块的导入器到sys.meta_path,最后执行入口脚本。所以程序跑起来之后sys.frozen为True,sys._MEIPASS指向那个解压目录,__file__的含义也跟着变了——凡是在代码里靠__file__拼路径的,打包后基本都会断。
注意:
_MEIxxxxxx目录名每次启动都不同。写日志、写缓存时把它当固定路径用,是新手最容易踩的一脚。
2.3 单文件与目录模式:代价不在打包,在每一次启动
-F和-D的差别不是"一个文件还是多个文件"这么表面,它决定了启动时的 I/O 形态。单文件模式下,每次双击都要把整个料包解压一遍,包体 200MB 就意味着每次启动先读写 200MB;目录模式直接从磁盘加载,没有这个开销。曾经有个项目图省事用了-F,带着一堆科学计算库,产物 300MB 出头,客户机上冷启动要七八秒,用户以为程序卡死了,反复双击开出好几个实例——这就是打包选项选错带来的血泪经验。
| 维度 | -F单文件 | -D目录模式 |
|---|---|---|
| 产物形态 | 一个 exe | 一个目录 + 若干文件 |
| 启动开销 | 每次全量解压,与包体成正比 | 无解压,接近原生 |
| 崩溃残留 | 异常退出可能留下_MEI临时目录 | 无 |
| 杀软敏感度 | 偏高,自解压行为容易被判可疑 | 相对温和 |
| 分发方式 | 直接发 exe | 压 zip,或做安装包 |
| 换文件调试 | 必须重新打包 | 可直接替换目录内文件验证 |
我的选型习惯是:交付给外行用户、体积小于 30MB 的小工具,用-F图省事;带 GUI、带大量依赖、或者需要现场替换资源排查问题的,一律-D,最后用压缩包或安装包交付。别为了"看起来干净"牺牲启动速度,用户对慢的容忍度远低于对"多个文件"的容忍度。
2.4 spec 文件:真正被执行的构建脚本
敲pyinstaller main.py的时候,它做的第一件事是生成一份main.spec,然后执行这个 spec。命令行参数只是往 spec 模板里填值。这意味着两件事:一是 spec 是纯 Python 脚本,可以写判断、读环境变量、按平台分支;二是每次直接对.py跑 pyinstaller,都会重新生成 spec,把你手改的内容覆盖掉。
所以正确的姿势是分两步走:第一次用命令行生成 spec 并跑通,然后改 spec,之后所有构建都改成pyinstaller main.spec。这个习惯我从踩坑之后就一直保持着,因为手改的hiddenimports和datas一旦被覆盖,重新找回来要花半小时。
3. 从命令行到 spec:把一个脚本打成可交付 exe 的完整流程
链路清楚了,接下来是动手。这一章按"造环境 → 用命令行跑通 → 改 spec 固化 → 管理产物"四步走,每一步都给出可以直接抄的命令和需要盯的参数。
3.1 先造一个干净的打包环境
PyInstaller 的收集逻辑是"能扫到就带走",所以打包环境里装了什么,直接影响产物。在全局环境里打包,site-packages 里几百个包会被挨个分析,产物虚胖不说,还可能因为同名模块冲突打出诡异的运行时错误。我一般专门建一个 venv,只装这个程序运行期真正需要的依赖,打完就删。
版本匹配是另一个硬约束。3.2.1 这一代对应的是 Python 3.6 及更早的解释器,拿它去跑 3.7 以上的解释器,会在导入钩子和字节码层面出问题,报错信息还很不直观。所以环境必须锁死。
# 建一个只用于打包的干净环境 python3.6 -m venv pyi-env source pyi-env/bin/activate # Windows 下用 pyi-env\Scripts\activate python -V # 必须确认是 3.6.x,别跳过这步 # 锁定版本安装,别用不带版本号的 install pip install "pyinstaller==3.2.1" pip install -r requirements.txt # 只装运行期真正需要的包 pyinstaller --version # 输出 3.2.1 才算装对代码逻辑很简单,关键是三处:python3.6 -m venv保证解释器版本,==3.2.1保证工具版本,requirements.txt保证依赖清单受控。如果内部源上取不到这个版本,就从能连通的环境里pip download出 wheel 再离线安装,不要试图用新版本"凑合一下",兼容问题会在打包后期集中爆发。参数上唯一值得改的是 venv 路径,建议放在一个短路径下,避开中文和空格。
3.2 六组命令行参数,覆盖八成场景
参数不少,但真正天天用的就那么几组。下面这张表按使用频率排,右边一列是我自己的使用判断。
| 参数 | 作用 | 什么时候用 |
|---|---|---|
-F/-D | 单文件 / 目录模式 | 小工具-F,带 GUI 或大依赖-D |
-c/-w | 保留控制台 / 去掉控制台 | 调试期一律-c,交付前再换-w |
-n NAME | 指定产物名 | 必填,别让产物叫main.exe |
-i app.ico | 指定图标 | Windows 只认.ico,多尺寸最稳 |
--add-data "src;dst" | 附带数据文件 | 有配置文件、模板、字体时必须 |
--hidden-import MOD | 补一个扫不到的模块 | 报ModuleNotFoundError时逐个补 |
--exclude-module MOD | 排除模块 | 削体积,排除测试包和用不到的重型库 |
--clean | 清掉缓存和旧 TOC | 改了依赖或删了模块之后必须加 |
--log-level=INFO | 提高日志详细度 | 打包过程报错时定位用 |
--noupx | 禁用 UPX 压缩 | 被安全软件拦截时首选 |
两条常用命令,直接抄:
# 调试期:保留控制台,能看见 traceback pyinstaller -D -c -n demo_app --clean --log-level=INFO main.py # 交付期:单文件、无控制台、指定图标 pyinstaller -F -w -n demo_app -i icons/app.ico --noupx main.py第一条的-c是关键,所有"双击闪退"的问题都靠它暴露;--clean会清掉build/下的旧 TOC,防止构建结果里混进已经删掉的模块。第二条里的--noupx是防御性选择,UPX 能把体积压下去三分之一,但压缩后的高熵特征在安全软件眼里和加壳程序很像,误报率明显上升,交付给外部用户的包我一律关掉它。
3.3 手改 spec:datas、hiddenimports、excludes 三处最常动
spec 生成好之后,真正需要动手改的通常只有三个位置。不要让命令行参数越堆越长,固化进 spec 才是可维护的做法。
# demo_app.spec —— 只展示需要改动的位置,其余保持自动生成的样子 a = Analysis( ['main.py'], pathex=['.'], binaries=[], # 元组含义是 (源路径, 打包后放的目录),不是 (源, 目标文件名) datas=[ ('config/config.yaml', 'config'), ('templates', 'templates'), ('assets/fonts', 'assets/fonts'), ], # 静态分析扫不到的模块,在这里补 hiddenimports=[ 'pkg_resources', 'yaml', 'demo_app.plugins', ], hookspath=['hooks'], # 自定义 hook 目录 runtime_hooks=[], # 明确排掉的东西,比指望分析器猜要靠谱 excludes=[ 'tkinter', 'unittest', 'pydoc_data', 'matplotlib.tests', 'numpy.testing', ], win_no_prefer_redirects=False, win_private_assemblies=False, )三处改动的逻辑说明:datas里每个元组是"从哪拿"和"放到产物的哪个相对目录下",第二项必须是目录,写成文件名是常见错误,会导致资源落到根目录而代码找不到;hiddenimports是把静态分析漏掉的模块硬塞进去,补之前先看warn-<name>.txt,别凭感觉加;excludes是主动瘦身,把测试包、文档数据、GUI 库里没用到的部分排除掉,一个tkinter就能省下十几兆。
改完后用pyinstaller demo_app.spec构建,命令里不再带任何参数。参数一多就记不住,也没法进版本管理,这是 spec 存在的意义。
3.4 build 与 dist:两个目录各有各的用处
build/<name>/是中间产物,dist/<name>/是最终交付物。前者经常被当成垃圾直接删,其实里面有两个文件很有用:warn-<name>.txt记录了所有找不到的模块,缺依赖时看它;xref-<name>.html是模块引用关系图,想知道"某个库是被谁拖进来的"——比如排查为什么tkinter明明没写却被打进去了——打开这个文件搜一下就有答案。
dist/目录则是验证的地方。改了 spec 重新构建后,我一般会先删掉build/再跑,虽然加了--clean也行,但 3.2.1 的增量构建在删模块之后容易残留旧条目,产物里躺着一个已经删掉的包的字节码,排查起来非常费劲。宁可多等两分钟。
4. 资源、插件与多进程:三类"扫不到"的依赖怎么补
打包报错基本可以归到三类:文件没带进去、模块没被扫到、子进程起不来。这三类的成因和处理方式完全不同,混在一起试只会浪费时间。这一章分开讲。
4.1 --add-data 的分隔符与 sys._MEIPASS 取路径
数据文件的问题通常出在两个地方:一是没带进去,二是进去了但运行时定位不到。前者的坑在分隔符——--add-data的源和目标之间,Windows 用分号;,Linux 和 macOS 用冒号:。在 Windows 上写成冒号,路径会被从中间截断(因为C:\本身就带冒号),打包不报错,运行时文件凭空消失,非常隐蔽。
# Windows:分隔符是分号 pyinstaller -F --add-data "config/config.yaml;config" main.py # Linux / macOS:分隔符是冒号 pyinstaller -F --add-data "config/config.yaml:config" main.py运行时的取路径要统一走一个函数,不要在业务代码里散落open('config/config.yaml'):
import os import sys def resource_path(rel_path): """打包后从 _MEIPASS 取,源码运行时从脚本目录取。""" base = getattr(sys, '_MEIPASS', None) if base is None: base = os.path.dirname(os.path.abspath(__file__)) return os.path.join(base, rel_path) # 用法:无论源码跑还是打包后跑,路径都正确 cfg = resource_path('config/config.yaml')sys._MEIPASS是 bootloader 在启动时注入的属性,单文件模式下指向%TEMP%\_MEIxxxxxx,目录模式下指向 exe 所在目录。用getattr加默认值,是为了让同一份代码在源码环境和打包环境都能跑,省得维护两套路径逻辑。参数上唯一要注意的是rel_path必须和 spec 里datas的目标目录保持一致,两边对不上就等于没打。
4.2 动态导入与 hook:让 hiddenimports 有地方落
插件式架构的程序最容易在这里翻车。主程序扫描plugins/目录,用importlib.import_module逐个加载,静态分析只看到"有个字符串在拼模块名",自然一个插件都不会打进去。表现是开发机上完美,打包后插件全部失联,日志里一行ModuleNotFoundError都没有,因为异常被吞了。
# 动态加载插件:这种写法静态分析一定扫不到 import importlib import os def load_plugins(pkg_dir): for name in os.listdir(pkg_dir): if not name.endswith('.py') or name.startswith('_'): continue mod_name = 'demo_app.plugins.' + name[:-3] # 运行时才知道要导入谁 importlib.import_module(mod_name)解决办法有两个层次。简单场景就在hiddenimports里把插件模块名一个个列全,插件数量固定时够用。插件会动态增加的项目,写成 hook 更省事:在hooks/下放一个hook-demo_app.py,里面直接给hiddenimports赋值,构建时会被自动合并进去。
# hooks/hook-demo_app.py # hook 文件名必须以 hook- 开头,后面跟包名 hiddenimports = [ 'demo_app.plugins.csv_loader', 'demo_app.plugins.json_loader', 'demo_app.plugins.xml_loader', ]这个文件由 PyInstaller 在分析阶段自动执行,作用等价于在 spec 里写hiddenimports,好处是跟包绑定、可以随源码一起管理,不必每换一个入口脚本就复制一遍。注意文件名规则,写错了不会报错,只是静默不生效,这个哑巴亏我吃过一次。
4.3 multiprocessing 与子进程:freeze_support 不是可选项
Windows 下用多进程的程序,打包后会出现一个很吓人的现象:进程数指数级增长,任务管理器里全是你的 exe,机器直接卡死。成因是 Windows 的spawn启动方式会在子进程里重新执行入口模块,而冻结之后没有解释器的引导逻辑,子进程实际上是又启动了一遍 exe,然后再孵化子进程,如此递归。
import multiprocessing def worker(q): q.put('done') if __name__ == '__main__': # 打包成 exe 后,这一行是必须的,缺了会无限孵化子进程 multiprocessing.freeze_support() q = multiprocessing.Queue() p = multiprocessing.Process(target=worker, args=(q,)) p.start() p.join() print(q.get())freeze_support()的作用是检测当前进程是不是 PyInstaller 拉起来的子进程,如果是就立刻返回、不执行后面的业务代码。它的位置必须在if __name__ == '__main__':之后的第一行,放到函数里或者条件分支里都可能失效。这个函数在源码环境里是空操作,加上不会有任何副作用,所以别犹豫,直接加。
同样的思路适用于所有会重启解释器的场景:subprocess调用自身、concurrent.futures的进程池、某些 GUI 框架的守护进程。判断标准很简单——只要这段代码在运行时会"再启动一个 Python 解释器",就要考虑冻结后的行为差异。
4.4 二进制依赖:binaries 与 datas 的区别
--add-binary和--add-data长得像,作用对象不同。前者放的是.pyd、.dll、.so这类可加载的二进制,PyInstaller 会对它们再做一次依赖扫描,把二级依赖的 DLL 一并带进来;后者只是原样复制,不做任何分析。用ctypes加载的自研库、或者通过dlopen动态载入的第三方库,必须走 binaries,否则运行时找不到入口点,报的错还和"文件不存在"完全不像。
在 spec 里对应的是binaries=[('path/to/native.pyd', '.')],元组含义与datas一致。判断该放哪边的标准:能被import或LoadLibrary加载的走 binaries,需要open()读的走 datas。放错位置不会报错,只会在运行时以各种离奇的方式失败。
5. 排查清单:五类翻车现场的现象、成因与处置
前面讲的都是"应该怎么做",这一章讲"做砸了怎么救"。下面五条按出现频率排,每条都按现象、成因、处置三段写,遇到问题时可以直接对号入座。
5.1 双击闪退,命令行里才有 traceback
现象:双击 exe 窗口一闪而过,什么都没留下,事件查看器里也没有有效信息。
成因:用了-w去掉控制台,程序启动初期的异常(缺模块、缺资源、配置解析失败)没有输出目标,异常信息随进程一起消失。更麻烦的是单文件模式下临时目录会被清理,连日志都没地方找。
处置:调试期一律用-c构建,从命令行启动,traceback 直接打屏。已经交付的包来不及重打,用cmd /k your_app.exe启动,窗口不会自动关闭。实在拿不到输出,就在入口最外层包一层 try/except,把异常写进%APPDATA%下的日志文件——这是黑匣子,出问题时能救命。
5.2 目标机报缺 DLL 或 "Failed to load Python DLL"
现象:开发机运行正常,拷到客户机上弹出缺少api-ms-win-crt-runtime-l1-1-0.dll、vcruntime140.dll,或者直接提示无法加载 Python DLL。
成因:新版 Python 依赖 Universal CRT 和 VC++ 运行库,PyInstaller 会把python36.dll带进包,但系统级的运行库不在它的收集范围里。另外位数不匹配(32 位程序跑在纯 64 位环境、或反之)也会报类似错误。
处置:确认打包用的解释器位数与目标系统一致,32 位 Python 打出来的就是 32 位程序;目标机安装对应版本的 VC++ 可再发行组件,这是最省事的方案。要做免安装交付,就在 spec 的binaries里把vcruntime140.dll和相关 CRT 库显式带上,让它跟着包一起走。别指望"用户自己会装"。
5.3 杀软拦截与 UPX 压缩的副作用
现象:产物在自己机器上好好的,发出去十分钟就被反馈"被杀毒软件删了",或者运行时被拦截、进程被终止。
成因:单文件 exe 的自解压行为本身就敏感,叠加 UPX 压缩之后熵值升高,静态特征和加壳恶意程序高度相似,启发式引擎很容易误判。没有数字签名的产物更是雪上加霜。
处置:先关 UPX,--noupx或在 spec 里设upx=False,多数误报会消失。仍有问题就改走目录模式,把整个目录压成 zip 分发,安装形态比自解压 exe 温和得多。长期方案是给产物做代码签名,并整理一份说明提交给安全厂商做误报申诉。改文件名、改图标这类做法完全无效,别浪费时间。
5.4 中文路径、中文程序名与图标不生效
现象:-i指定的图标打进去还是默认图标;或者构建目录带中文时打包中途报编码错误;又或者程序名用中文,在某些机器上双击无反应。
成因:图标格式不对是首要原因,Windows 只认.ico,把 png 改名成 ico 是最常见的自欺欺人;其次是老版本 bootloader 对非 ASCII 路径的处理不够稳,路径里带中文或全角空格时容易在解压环节出错。
处置:图标用工具转成包含 256/128/64/48/32/16 多尺寸的 ico 文件;构建目录放在纯 ASCII 的短路径下,比如D:\build,产物名也用英文加下划线。中文只出现在界面上,不要出现在路径和文件名里。这条听起来像老生常谈,但每次项目交接都能遇到。
5.5 开发机能跑、换台机器就崩
现象:同一份 exe,在自己机器上反复测试都正常,到同事或客户机器上启动即退出,没有任何提示。
成因:最常见的是程序试图往 exe 所在目录写文件,装到Program Files下就没有写权限;其次是打包环境不干净,某个模块是靠开发机特有的路径被扫进去的,换台机器构建就没了;还有一种是无控制台模式下,权限错误直接吞掉异常。
处置:所有运行时产生的数据(日志、缓存、配置)一律写到%APPDATA%或用户目录下,绝不用 exe 所在目录;构建统一在干净的 venv 里做,构建脚本进版本管理,保证任何人任何机器构建结果一致;验证时专门用一个非管理员账户跑一遍,权限问题会立刻暴露。
6. 交付前的三道验证与一个可复用的构建脚本
打包这件事,"我这能跑"和"交付没问题"之间隔着一段距离。我的习惯是交付前固定跑三道验证,缺一道都不发。
| 验证项 | 做法 | 能暴露的问题 |
|---|---|---|
| 干净虚拟机 | 全新系统镜像,不装 Python 和任何运行库 | 缺 DLL、缺 CRT、路径假设 |
| 非管理员账户 | 用普通用户登录运行 | 写入权限、注册表访问 |
| 断网运行 | 断开网络启动并走一遍主流程 | 隐式的在线依赖、超时卡死 |
三道验证做完,基本能覆盖九成的现场事故。虚拟机这一步尤其别省,开发机上装了 VC++ 运行库和一堆 Python 版本,很多缺失问题在自己机器上永远复现不出来。
构建脚本把参数固化下来,避免每次靠回忆敲命令:
#!/usr/bin/env bash # build.sh —— 放到项目根目录,构建前先激活打包专用 venv set -e APP_NAME="demo_app" # 清掉旧产物,避免增量构建残留已删除的模块 rm -rf build dist # 统一走 spec 构建,不再往命令行堆参数 pyinstaller --clean --noconfirm --log-level=INFO "${APP_NAME}.spec" # 打包完成后立刻检查 warn 文件,有缺失模块就人工确认 echo "----- 缺失模块清单 -----" cat "build/${APP_NAME}/warn-${APP_NAME}.txt" | head -n 40 echo "产物在 dist/ 下,接下来跑三道验证:干净虚拟机、非管理员、断网。"脚本里的set -e让任何一步失败都立刻中止,避免拿着半成品去测试;rm -rf build dist配合--clean双保险;最后把 warn 文件的前几十行打出来,是强制自己每次扫一眼——大部分缺模块的问题在这一步就能发现,不用等到用户反馈。Windows 下把同样的逻辑写成.bat,rm -rf换成rmdir /s /q即可,其余不变。
还有一个细节值得单独说:产物名里带上日期或提交号,demo_app_20240612这种。出了问题能立刻确认对方手上是哪一版,省掉"你发我的是哪个包"这种来回扯皮。
从那以后我每次交付前,都会在一个什么都没装的虚拟机上用普通账户断网跑一遍主流程,确认没问题才发出去。这个习惯救过我至少三次——一次是缺 CRT,一次是往安装目录写缓存,一次是启动时偷偷请求了一个接口导致超时卡死。希望帮到你。
本文还有配套的精品资源,点击获取