简介:PyInstaller 3.2.1是一份面向Python开发者的跨平台打包工具资源,旨在解决Python程序在无解释器环境下的分发难题,适合需要将脚本封装为独立可执行文件的入门及进阶用户。压缩包内含790个文件,约3.01MB,以py源码、c/c扩展实现、h头文件、rst文档、spec打包配置及exe可执行程序等类型为主,便于从源码与配置层面理解打包机制。已有599人参与学习。资源保留了PyInstaller-3.2.1的完整工程结构,涵盖核心C模块(如pyi_launch、pyi_archive)与构建脚本,同时也包含spec示例、图标资源和说明文档,读者可据此研究依赖分析、单文件/多文件发布及跨平台构建的具体实现,或直接提取工具进行本地打包测试。对于想深入掌握PyInstaller工作原理或定制打包流程的开发者,具有不错的参考与实用价值。
1. PyInstaller 3.2.1:老版本打包器凭什么还值得上手
PyInstaller 3.2.1 是 Python 2.7/3.5 时代流行的打包器版本,现在你会搜到它,多半不是追新,而是手里压着一个只能用 Python 3.5 解释器跑的旧工程。它解决的核心问题很具体:把脚本连同依赖解释器一起打成可执行文件,目标机器不用装 Python。实际场景里,很多人是先装新版 PyInstaller 被版本兼容性卡住,换回 3.2.1 反而一步到位。它旧,却因为默认行为更直白,更适合把打包这件事讲清楚。下面从环境对齐讲到交付验证,连带把最容易踩的几个坑标好定位。
2. 把 3.2.1 用起来:环境兼容性核对与最小打包命令
2.1 环境对齐:3.2.1 能管哪些 Python 版本
PyInstaller 3.2.1 那代支持的 Python 是 2.7、3.3、3.4、3.5,4.0 之后的解释器它不认识。换句话说,如果你机器上装的是 Python 3.8 或更高,第一优先要做的事不是调参数,而是给这个旧工程单独准备一个干净环境。先把当前环境看清:
python --version pip --version输出确认 Python 版本后,再执行安装:
pip install pyinstaller==3.2.1==3.2.1把版本号锁死,避免 pip 因为依赖解析给你装出一个 3.3、4.x 的新版。我一般会顺手pip show pyinstaller看一眼安装路径,确认它和你正在用的解释器属于同一个虚拟环境。很多人翻车在“命令能跑,但跑的是另一个环境里的 PyInstaller”,导致打出来的包和分析结果对不上。
注意:Python 3.5 太老,新版 pip 可能拒绝安装。遇到这种情况,先在老环境里用
python -m pip install pip==19.3.1把 pip 拉回老版本线,再去装 PyInstaller 3.2.1。别先把 pip 升到最新,否则 pip 自己就会因为解释器太老而罢工。
环境对齐这一步没有技巧可言,纯粹是版本匹配问题。记住一个原则:项目解释器是什么年代,打包器就用什么年代;跨一个大的 Python 主版本去强行兼容,后面所有 hook、隐藏导入都会变成玄学问题。
2.2 最小打包命令:从脚本到可执行文件的第一次成功
先建一个最小脚本,专门用来验证 3.2.1 在当前环境能不能正常完成采集、归档、链接三步:
# hello.py -- 最小验证脚本 import sys import time def main(): print("hello from PyInstaller 3.2.1") time.sleep(2) # 双击运行时别让窗口秒退 input("press enter to exit...") if __name__ == "__main__": main()time.sleep(2)是给控制台运行留出观察时间,input()让窗口停在那边等你按下回车;在双击 exe 的场景里,这两行能避免“看起来像没反应”的误会。接着打包:
cd /path/to/hello pip show pyinstaller pyinstaller -F -c -n hello hello.py-F表示打成单个可执行文件,-c表示使用控制台窗口,-n hello指定产物名字。命令执行完,Windows 下会在dist\hello.exe找到产物;Linux/macOS 下则是dist/hello,没有扩展名。如果这步能跑通,说明这个 3.2.1 和你手里的 Python 环境没有版本冲突,接下来才有资格讨论数据文件、隐藏导入这些进阶话题。
有两个细节值得解释。-F在 3.2.1 里不是默认行为,默认是目录模式;你显式写-F,后面换到别的机器、别的平台,行为才会一致。-c在打包 GUI 程序时通常要换成-w,但第一次跑一定要保留控制台,因为 Python 的报错默认只进控制台,窗口关掉你就看不见了。
跑完控制台会输出一堆INFO: Analyzing...、INFO: Building EXE...日志,看到completed successfully就算过了。此时在命令行里执行dist\hello.exe,如果打印出hello from PyInstaller 3.2.1并停在“press enter”,基础链路就通了。
2.3 生成物拆解:spec、build、dist、warn 文件各自是什么
一次打包之后,工程目录里会多出几样东西,先认识它们再往后走:
| 文件/目录 | 作用 | 该不该进版本库 |
|---|---|---|
dist/hello.exe | 最终交付物 | 不提交,产物 |
build/hello/ | 打包中间缓存,可整目录删除 | 不提交 |
hello.spec | 下次打包的配置输入 | 提交,作为唯一构建入口 |
warn-hello.txt | 模块收集警告 | 提交到 CI 日志,排查用 |
hello.spec是命令行参数被 PyInstaller 展开后的 Python 脚本。以后改数据文件、加隐藏导入,可以直接改 spec 再执行pyinstaller hello.spec,比每次敲一长串参数可靠。build/目录是分析过程留下的中间产物,删掉不影响结果,但它能帮你确认收集了哪些文件,遇到“打包后变慢”时可以看它。
warn-hello.txt是排查缺少模块的第一现场。打包完成后打开它,如果里面出现你代码里明明 import 过却标着Module not found的提示,说明这模块是静态扫描漏掉或者被条件导入绕过了,后面要按下一章的hidden-import补。这个文件不算强制输出,但 3.2.1 默认会生成,留着它等于给现场留了黑匣子。
从这章开始,我的习惯是先跑一次最小命令验证环境,再决定要不要上 spec。环境不通时改再多参数都是白费;环境通了,后面加数据文件、补隐藏导入才有意义。在客户端现场看到报错就乱调参数,是大多数人卡住半天的真正原因。
3. 打包真实工程必调的三个东西:spec、数据文件、动态导入
3.1 数据文件怎么带:--add-data 的分隔符和运行时路径
脚本里读配置文件、图片、模板时,开发环境用的是相对路径,打包后 exe 的工作目录和文件位置都变了。3.2.1 处理数据文件的办法是--add-data,把源文件打进包里,运行时由sys._MEIPASS指向解压目录。命令长这样:
# Linux/macOS 用冒号分隔源路径和目标目录 pyinstaller -F --add-data "res/config.ini:res" app.py # Windows 路径分隔符是分号;整段参数用双引号包住 pyinstaller -F --add-data "res\config.ini;res" app.py3.2.1 的行为是源路径:目标目录,目标目录是相对运行时根目录的。这里的坑在分隔符:Linux 和 macOS 路径用冒号,Windows 路径用分号,不少人在 Windows 上照抄 Linux 写法的结果是res\config.ini:res被解析成C:\...\res\config.ini:res,直接报找不到路径。我一般会在pyinstaller命令后面加--log-level WARN,专门看它有没有丢掉数据文件。
运行时在代码里取这个文件的路径,不能写死绝对路径:
# app.py import os import sys def resource_path(relative): # 打包后 sys._MEIPASS 指向临时解压目录;源码运行返回当前目录 base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, relative) cfg_path = resource_path("res/config.ini")getattr(sys, "_MEIPASS", ...)是关键:打包后的解释器环境里有_MEIPASS,源码环境没有,这一行同时兼容两种运行方式。注意这个函数要写成模块级函数,不要在内部又依赖__file__,因为 PyInstaller 启动后__file__指向的是临时目录里的解压文件,不稳定。
数据文件的另一个注意事项是别把只读模板和运行时日志混在一个目录。常见做法是:模板、默认配置打进包内,日志、用户配置写到系统应用数据目录。否则每次更新程序,旧日志会被新包覆盖,用户配置也容易被误删。
3.2 动态导入怎么补:hidden-import 和 hook 文件
PyInstaller 通过静态扫描import语句来收集模块,但遇到按名字拼接、运行时才确定的模块名,它就扫不到。典型场景是插件系统:
# 运行时按配置选择引擎模块,模块名是拼出来的 plugin_name = "engine_" + os.environ.get("ENGINE_MODE", "fast") engine = __import__("engine." + plugin_name, fromlist=["*"])这类代码在源码里没有任何字面上的engine_fast、engine_standby,PyInstaller 分析阶段根本不知道它们存在。打包后程序一运行就ModuleNotFoundError。解决办法是显式告诉它:
pyinstaller -F --hidden-import=engine_fast --hidden-import=engine_standby app.py--hidden-import可以反复出现,每个模块名写一次。当模块数量多、还带数据文件时,更稳的做法是写 hook。hook 是一个 Python 文件,文件名约定为hook-包名.py,放在专用目录:
# hooks/hook-engine.py from PyInstaller.utils.hooks import collect_submodules # 收集 engine 包下所有子模块,不用一个个手写 hiddenimports = collect_submodules("engine")然后在打包时指定这个目录:
pyinstaller -F --additional-hooks-dir=./hooks app.pycollect_submodules("engine")会递归收集包内全部子模块,适合插件成堆的工程;它返回一个列表,会自动合并到最终模块列表里。这里有个边界:collect_submodules只收集模块,不收集非.py资源文件,资源还得靠--add-data或 hook 里返回datas列表。
怎么判断一个模块是不是“藏起来”了?最简单的方法是打包之前,先把程序用源码方式跑一遍;如果源码环境能过、打包后不能过,第一反应就是查warn-*.txt里有没有对应模块名。高手和新手的差距也在这里:新手一遍一遍重打包,老手先看 warn 再补 hidden import。
3.3 spec 文件手工调:从命令行参数到唯一构建入口
命令行参数适合第一次快速验证,工程一复杂,参数会越来越长、越来越难维护。3.2.1 在第一次打包后自动生成<工程名>.spec,我习惯把它改成唯一构建入口。一个整理过的 spec 像这样:
# app.spec -- 对应 -F 单文件模式 a = Analysis( ["app.py"], pathex=[], binaries=[], datas=[("res/config.ini", "res")], hiddenimports=["engine_fast", "engine_standby"], hookspath=["./hooks"], runtime_hooks=[], excludes=["pydoc", "test", "pytest", "unittest"], noarchive=False, ) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, a.binaries, a.datas, name="app", debug=False, strip=False, upx=True, console=True, )datas对应命令行里的--add-data,hiddenimports对应--hidden-import,hookspath指明 hook 目录。excludes里列出确定用不到的模块,明确告诉 PyInstaller 不要打包它们;noarchive=False表示把 Python 字节码打进 PYZ 归档,这是默认的压缩方式。这个 spec 的EXE段把binaries和datas直接并进来,所以对应的是-F单文件模式;如果你打包时用的是默认目录模式,生成的 spec 里还会有COLLECT段,把依赖收成目录。
改完 spec 后,再打包就简单了:
pyinstaller app.spec注意一个行为:当输入是 spec 时,命令行里的-F、-n这些选项很多会失效,真正起作用的是 spec 里的字段。所以不要在敲pyinstaller app.spec -F之后发现没变单文件而困惑,去看 spec 里的EXE(...)那段怎么写的。这就是为什么 spec 必须要进版本库——它是整个打包过程的唯一事实来源。
另外,补一个 spec 时代常见的后悔药:如果发现 spec 被改乱,不必手工还原,回到命令行参数重新生成一份就行。PyInstaller 每次打包都会用命令行参数重建 spec。所以“改乱了就删掉 spec 重新生成”,这招在 3.2.1 里照样适用。
4. 构建模式与运行行为:onefile 与 onedir 到底差在哪
4.1 四种构建模式怎么选
PyInstaller 3.x 时代,构建模式的核心维度到今天也没变:-F/--onefile与-D/--onedir决定产物形态,-c/--console与-w/--windowed决定是否弹出控制台窗口。两者组合,就得到了实际使用中最常见的四种:
| 参数组合 | 产物形态 | 适用场景 |
|---|---|---|
-F -c | 单文件 + 控制台 | 命令行工具,交给运维直接跑 |
-F -w | 单文件,无控制台 | GUI 程序对外交付 |
-D -c | 目录 + 控制台 | 调试、内部脚本分发 |
-D -w | 目录,无控制台 | GUI 程序内部部署 |
3.2.1 的默认行为是-D -c,也就是生成一个包含 exe 和一堆依赖库的目录,运行时从目录里直接加载,启动快、安全软件误报率低。很多教程上来就写-F,把单文件当作默认,这是误解。我一般会建议:项目调试阶段用-D -c,正式交付时如果客户只接受一个文件再换-F,千万别一开始就被“单文件崇拜”带偏。
单文件模式看着干净,代价在运行时:每次启动都要先把归档里的文件解压到系统临时目录,程序退出后再清理。文件越多、越大,启动越慢,某些环境里安全软件监控临时目录还会再拖慢一次。目录模式下这些依赖就在 exe 旁边,操作系统直接加载,启动速度和调试体验都更接近源码运行。
选择的关键不是“单文件看起来更专业”,而是交付现场允不允许你带一个目录。只给运维一个dist/app.exe很爽口,但前提是你要接受启动慢、误报概率更高的现实。
4.2 控制台与窗口模式:调试期保留 console 是底线
一个 GUI 程序如果打包成-w,双击时不再弹出黑框,看着很干净。但代价是 Python 的print、traceback也无处可去,程序在启动阶段异常,你连个报错都看不见,只能猜。不少人是靠-w打包后拖进命令行执行,用重定向的方式看输出,绕一大圈。
我的建议很简单:开发测试阶段永远用-c,只有交付前才切-w。切-w之前,先回到源码目录用python app.py跑一遍,确认没有打印依赖、没有需要控制台交互的逻辑。如果程序里有input(),在-w模式下它不会等到输入而是直接抛EOFError,这个行为 3.2.1 也一样,属于经典翻车点。
如果你必须用-w且需要看报警,可以在入口函数包一层日志捕获,把未捕获异常写进文件:
# app.py import traceback def run(): # 你的程序主体 pass if __name__ == "__main__": try: run() except Exception: with open("app_error.log", "w", encoding="utf-8") as f: traceback.print_exc(file=f) raise这里的关键是with open("app_error.log", "w", ...)的路径。它写的是进程当前工作目录,不是 exe 所在目录;如果程序在任务计划或快捷方式里以其他目录为工作目录启动,日志可能跑到你找不到的地方。稳妥做法是把日志路径也交给resource_path()那种基于_MEIPASS或系统应用数据目录的解析,而不是裸写文件名。
4.3 运行时临时目录:sys._MEIPASS 与启动行为的黑匣子
-F模式下打包的 exe 启动时,bootloader 会把归档里的所有文件释放到临时目录,目录名通常以_MEI开头。Python 侧看到的sys._MEIPASS就是那个路径。这段机制在 3.2.1 里是黑匣子,你不必完全理解它,但必须知道两个事实:路径是临时的,下次启动可能换;程序崩溃时残留文件不会自动清理。
验证当前进程用的是哪个临时目录,可以这样看:
import sys import os if hasattr(sys, "_MEIPASS"): print("unpack dir:", sys._MEIPASS) print("exe path:", os.path.abspath(sys.executable)) else: print("source mode")sys.executable在打包前后也有变化:源码模式下指向 python.exe,打包后指向你的 app.exe。利用这一点可以判断代码是运行在打包环境还是开发环境,比硬编码判断平台更可靠。
提示:单文件程序的临时目录路径由系统 TEMP 环境变量决定,空间不足时程序会在启动阶段就失败,表现是“双击没反应”。先查环境变量
TEMP、TMP指向的盘符剩余空间,往往比看代码跑得快。
明白了_MEIPASS的临时性,再去理解“为什么打包后配置文件改了不生效”就容易了。很多人把可写配置也打进包内,程序每次启动都从临时目录读初始值,用户改的配置写在别处,看起来就像程序“不受控”。正确做法是:静态资源进包,运行时可变数据走外部目录。
5. PyInstaller 3.2.1 高频坑位与排查手册
5.1 启动即闪退:窗口还没看清就没了
现象:双击 exe,控制台窗口一闪而过,程序直接消失。
原因:打包后的异常信息打印在控制台里,窗口关闭就看不到了。在-w模式,控制台都没有,更是无从下手。
解决:第一件事是改回-c重新打包,再到命令行里执行dist\app.exe,让异常信息留在窗口里。如果还是闪退,给入口函数加全局异常捕获并写入文件,按上一章的代码改。要注意:traceback.print_exc(file=f)写的是当前工作目录,抓到日志后先确认它的落盘位置,别一上来就怀疑代码逻辑。
这个坑里最容易被忽视的是“打包机正常、目标机闪退”。原因往往是目标机缺 Visual C++ 运行库,3.2.1 时代的产物依赖 VC++ 2015 Redistributable,目标机没装就可能在启动阶段直接退。这种闪退连异常文件都来不及写,因为 Python 环境还没完全启动。区分方法是看报错弹窗:缺 DLL 的报错来自系统加载器,缺模块的报错来自 Python,两者文字不同。
5.2 打包后提示找不到模块:源码里明明有 import
现象:开发环境python app.py一切正常,打包后的 exe 运行时报ModuleNotFoundError: No module named 'xxx'。
原因:静态分析只能识别显式的import xxx语句;__import__、importlib.import_module、模块名拼接、藏在try/except里的导入,都可能被漏掉。
解决:先打开warn-app.txt搜模块名,确认它被标成 missing;然后补--hidden-import=xxx,或者为整个包写 hook 按第 3 章的方式收集。补完重新打包,再看 warn 文件确认不再出现。
这个坑里还有一类特殊场景:你的代码里 import 了某个库,但这个库自身又用 C 扩展加载子模块,PyInstaller 收集不到 C 扩展里的依赖,表现同样是不停报“找不到模块”。处理方式是逐个补--hidden-import,没有更快的捷径。模块收集本质上是个递归猜解的过程,warn 文件就是你调解步骤的地图。
5.3 目录模式拷到别的机器依然崩:缺运行库和系统组件
现象:同一份dist目录,开发机运行良好,拷到一台干净的目标机上,启动报缺少VCRUNTIME140.dll类的错误。
原因:目录模式虽然把所有 Python 依赖带上了,但 VC++ 运行库属于系统组件,PyInstaller 不负责替你安装;目标机缺它,exe 在走完启动流程之前就挂了。
解决:在目标机安装对应版本的 VC++ 运行库再试。如果交付现场不允许装系统组件,就换成-F单文件试试——单文件模式有时能把需要的运行时 DLL 一并收进来,但并不能保证 100% 收齐,所以要在干净环境里验证。用 PE 查看工具检查 exe 的 DLL 依赖列表,能精确看出缺的是什么,比瞎试快得多。
这类问题的排查顺序应该是:先看系统事件日志里的错误模块名,区分是缺 DLL 还是缺 Python 模块;再在命令行下运行 exe 拿完整报错;最后才动打包参数。跳步乱试是大多数人在这里耗费一下午的原因。
5.4 安全软件把打包产物当风险文件隔离
现象:exe 刚生成,安全软件就提示检测到风险,直接隔离或删除,客户现场也复现。
原因:onefile 自解压、UPX 加壳、无数字签名的程序都容易触发启发式规则。PyInstaller 本身没问题,但“自解压 + 修改临时目录 + 网络请求”这些行为叠加在一起,恰好命中安全软件的敏感模式。
解决:先关掉 UPX 试一次,用upx=False或命令行去掉--upx;如果还误报,把交付形态从-F换成-D,目录模式的启动行为更接近普通程序,误报率明显低。有条件的话,正式对外分发用代码签名证书做签名,能显著降低主流安全软件的拦截率,但签名解决不了所有问题,要结合前三步一起处理。
误报现场最容易翻车的一点:开发机用某款安全软件不报,客户那款报,于是回来反复调参数。实际上应该第一时间拿到客户安全软件的确切检测名,按特征词判断命中原因是“自解压”“加壳”还是“可疑行为”,再针对性处理。没有检测名就改参数,等于盲调。
5.5 体积异常大或启动慢:排除模块与构建模式之间的平衡
现象:一个打印 “hello world” 的程序用-F打出很大体积的 exe,运行时首启还慢;一个很大的工程反而打出了更夸张的体积。
原因:PyInstaller 会把 import 到的库整包收集,很多库连带收集了大批用不到的子模块;onefile 的启动慢则是每次都要解压归档。
解决:针对不必要的库执行排除:
pyinstaller -F \ --exclude-module=tkinter \ --exclude-module=pydoc \ --exclude-module=matplotlib \ --exclude-module=pytest \ app.py--exclude-module的意思是“即使扫描到了也不打包”,适合确定用不到的测试框架和可视化库。体积降下来之后,启动速度自然会好一点;但如果还在追求单文件的低调启动,就得考虑换-D模式。很多项目最终选择“对外给单文件、内部部署用目录”,两头都占。
在 3.2.1 上,UPX 压缩也能明显缩小体积,但要注意:UPX 压缩某些库之后,运行时会因为解压加载不兼容而报错。遇到莫名的内存错误或 DLL 加载失败,先怀疑 UPX,用upx=False重打一次对比。压缩不是免费午餐,它是在体积和稳定性之间做取舍。
6. 交付前的最后一步:验证清单与体积瘦身技巧
6.1 在干净环境验证产物的最小检查单
打包机跑通不算完成,交付现场的行为才是真实行为。我习惯在交付前用一台没有 Python、没有开发工具的干净测试机跑一份检查单:
| 检查项 | 操作 | 期望结果 |
|---|---|---|
| 启动 | 双击 exe | 正常出现界面或控制台输出,无弹错 |
| 依赖 | 命令行运行并查看退出码 | 退出码 0,或按业务正常退出 |
| 数据文件 | 进入需要数据文件的功能页 | 配置、图片、模板能读,无中文乱码 |
| 写盘 | 执行业务预期会写日志/配置的操作 | 文件写到了外部目录,没有落在临时目录 |
| 清理 | 反复启动/退出 5 次 | 临时目录没有残留_MEI开头的文件堆积 |
如果有自动化条件,把这 5 项写成一个脚本放到目标机跑一遍,比人工点十次靠谱。没有条件的话,至少把“启动”“写盘”两项做成必检,这两个是翻车率最高的。
6.2 让 3.2.1 的产物跑得更稳的两个收尾技巧
第一个技巧:能目录交付就不要单文件交付。目录模式启动快、临时目录不用解压、误报率低,代价只是交付物从一个文件变成一个文件夹。对内部工具,这个代价几乎可以忽略。第二个技巧:把容易变化的数据从包里拿出来。版本更新时,依赖库和代码逻辑变了才需要重新打包,配置文件、模板如果外置,运维可以直接替换而不动 exe,改动范围小、风险低。
我现在处理遗留项目的习惯是:先花十分钟把warn文件过一遍,再决定用哪种模式交付;包里只放代码和静态资源,运行时数据一律外置。这个习惯让我少踩了无数次“打包一时爽,现场火葬场”的坑。希望帮到你。
本文还有配套的精品资源,点击获取