简介:围绕 Python 脚本打包为独立可执行文件的需求,这份以 pyinstall 命名的安装包面向需要分发程序、减少目标机器环境依赖的开发者,提供了一套轻量的工具源码与配套资料,可用于了解依赖收集、脚本冻结及跨平台构建的基本实现思路。压缩包为 gz 格式,共 19 个文件,整体约 37KB,主要由 10 个 txt 文本、5 个 py 脚本、2 个 pkg-info 元数据、1 个 cfg 配置文件及 1 个 regen-docs 文档生成相关文件组成,覆盖安装配置、核心脚本、测试样例与发布信息等环节。已有 589 人学习或浏览,适合研究早期 Python 打包工具结构、测试组织方式和文档生成流程的读者参考;包内测试文本与元数据也有助于理解工具行为验证及版本信息组织,便于按模块查阅源码结构与配套说明。
1. pyinstaller 安装包:为什么双击能跑,却总在别人机器上翻车
把脚本在本地跑通只要几秒,交给同事却要花半小时解释“先装 Python、再装依赖、记得勾选 PATH”,这是很多人第一次动打包念头的原因。pyinstaller 安装包做的就是把这个链条压平:它把解释器、第三方库、你的代码和资源一起收进一个可执行目录或单文件,双击即启动,目标机器不需要任何 Python 环境。听起来很省事,真正落地时最容易翻车的往往不是打包命令本身,而是资源路径、隐式导入和运行环境差异。这篇笔记按“先装、再打、再瘦、再排错、最后验证”的顺序走一遍,适合已经能写 Python、但第一次要把东西交付到别人电脑上的从业者。
2. 环境与命令:从 pip 安装到第一个可执行文件
2.1 为什么必须在干净虚拟环境里打包
打包失败的原因里,环境不干净占了大头。PyInstaller 的工作方式是:从入口脚本出发做静态导入分析,再叠加各个包的 hook 去补动态引用,最终把命中的模块和二进制文件收进产物。你在全局 site-packages 里装过的东西越多,分析链路上挂到的历史包、多版本共存目录、残留的 .pth 文件就越多,产物会莫名变胖,warn 文件里的噪声也会淹没真正的问题。
我一般会为每个交付物单开一个 venv,只装运行必需的最小依赖,构建工具本身也装在这个环境里,而不是全局。这样pip freeze出来的清单就是构建清单,换台机器能一比一复现。
# 在项目根目录创建独立构建环境 python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate python -m pip install -U pip pip install pyinstaller pip install -r requirements.txt # 只装运行期需要的包 # 把构建时的确切版本冻结下来,方便排查"昨天还能打"这类问题 pip freeze > requirements-build.txt这段命令的关键在最后一行。PyInstaller 不同小版本对 hook 的处理有差异,第三方包升级也可能突然引入新的动态导入,把构建时的版本固化下来,等于给自己留了一颗后悔药。requirements.txt和requirements-build.txt最好分开放:前者是运行依赖,后者包含打包工具本身,部署到服务器时不需要后面这份。
还有一个前置约束必须提前想清楚:PyInstaller 不做交叉编译。在哪个系统上执行,就产出哪个系统的可执行文件;给 ARM 机器打的包,得在 ARM 环境里构建,或者用--target-arch指定架构(目前主要覆盖 macOS 的 x86_64 与 arm64)。Linux 上还有 glibc 的向下兼容问题——在低版本系统上构建的产物可以跑在高版本系统上,反过来大概率报符号缺失,这是血泪经验,不是配置能绕过去的。
2.2 一条命令的七个参数与它们的实际影响
最简单的命令是pyinstaller main.py,产出一个目录。真正交付时几乎不会只用默认值,下面这条是我常用的模板:
pyinstaller \ --noconfirm \ # 覆盖 dist/build 目录时不交互确认,适合脚本化 --clean \ # 清掉构建缓存,改了 hook 或依赖后必须加 --name demo_app \ # 产物名,同时决定 spec 文件名 --icon assets/app.ico \ # 图标,仅 Windows / macOS 生效 --add-data "assets:assets" \ # 源目录:包内目标目录 --hidden-import "PIL._tkinter_finder" \ --exclude-module matplotlib \ --onedir \ main.py参数的含义和踩坑点值得逐条展开:
| 参数 | 作用 | 常见误用 |
|---|---|---|
-F/--onefile | 打成单个可执行文件 | 每次启动解压到临时目录,冷启动明显变慢 |
-D/--onedir | 输出目录,含主程序和依赖 | 分发时要整目录打包,少一个文件就起不来 |
-w/--noconsole | 不弹控制台窗口 | 程序里的 print、异常堆栈全部丢失,调试期别加 |
-i | 指定图标 | 只对 Windows/macOS 有效,Linux 下忽略 |
--add-data | 附带非代码资源 | Windows 旧写法用分号分隔,新版本统一用冒号更稳 |
--hidden-import | 强制收集模块 | 只写模块名,写文件路径无效 |
--collect-all | 收集包的数据、二进制、子模块 | 用多了体积翻倍,只对确实需要的包用 |
--add-data的分隔符是新手最容易栽的地方。Windows 上老教程写分号;,Linux 和 macOS 写冒号:,混用会得到“源路径不存在”的报错。从 6.x 开始官方推荐统一用冒号,如果你的版本较老,最稳的办法是别在命令行里写,直接改 spec 文件用元组表达。
-w的坑更隐蔽。加了无控制台之后,sys.stdout是None,任何print或日志 handler 往标准输出写都会抛异常,表现是“双击没反应”。调试阶段一律带控制台,确认稳定后再切-w,并且在代码里给日志加文件输出。
2.3 打包完成后先看这三类产物
命令跑完,工作目录里会多出build/、dist/和一个.spec文件。多数人只看dist/,其实真正的诊断信息在前两个目录里。
build/<name>/warn-<name>.txt是导入分析的结果清单,会列出所有“看到了引用但没找到实现”的模块。这里的内容分三类处理:一是真缺,比如你的代码里import yaml但环境没装,直接补装;二是可选依赖,比如某个库对uvloop的 try-import,目标平台根本用不上,忽略即可;三是平台特有模块,比如 Linux 下的pwd、grp在 Windows 构建时必然缺失,同样忽略。判断标准很简单——先在开发机上正常运行程序,再对照清单,只处理运行期真会走到的分支。
build/<name>/xref-<name>.html记录了每个模块被谁引用、最终有没有进包。遇到“打包后 import 失败但本地正常”,把模块名丢进去搜一下,能立刻看出它是被静态分析扫到的,还是压根没进图。
dist/里则是最终产物。6.x 之后 onedir 模式的结构变了:主程序在dist/demo_app/顶层,依赖统一收进_internal/子目录,这样目录看起来干净很多,但也意味着你不能假设“所有文件都和 exe 平级”,后面讲资源路径时会再提。
3. spec 文件:把命令行参数固化成可复现的构建脚本
3.1 Analysis → PYZ → EXE → COLLECT 的执行顺序
第一次执行打包命令后,PyInstaller 会在当前目录生成demo_app.spec。这个文件是 Python 语法,本质是一段构建脚本,之后所有构建都应该用pyinstaller demo_app.spec触发,而不是继续堆命令行参数。原因很实际:参数一多,命令就没法 review,改了哪一项没人看得出来;spec 进版本库之后,每次改动都是可 diff 的。
spec 的执行顺序是固定的四段:
# demo_app.spec —— 精简后的骨架,字段含义见下方说明 from PyInstaller.utils.hooks import collect_data_files, collect_submodules datas = [("assets", "assets")] datas += collect_data_files("certifi") # 收集包内自带的证书等数据文件 hiddenimports = collect_submodules("yaml") # 收集包的子模块,避免动态导入丢失 a = Analysis( ["main.py"], # 入口脚本,可以有多个 pathex=[], # 额外的模块搜索路径 binaries=[], # 非 Python 的 dll / so datas=datas, # (源路径, 包内目标目录) hiddenimports=hiddenimports, hookspath=[], # 自定义 hook 目录 runtime_hooks=[], # 运行期钩子,在入口脚本前执行 excludes=["matplotlib", "pytest", "IPython"], noarchive=False, ) pyz = PYZ(a.pure) # 把纯 Python 模块压进归档 exe = EXE( pyz, a.scripts, [], exclude_binaries=True, # onedir 模式,二进制交给 COLLECT name="demo_app", debug=False, strip=False, # Linux/macOS 下可剥离符号表,Windows 无效 upx=True, console=False, # 等价于命令行的 -w icon="assets/app.ico", ) coll = COLLECT( exe, a.binaries, a.datas, strip=False, upx=True, name="demo_app", )Analysis负责扫描与分类,把模块分成纯 Python(进 PYZ)、二进制(进 binaries)、数据文件(进 datas);PYZ打成归档;EXE生成引导程序;COLLECT把目录拼起来。要走单文件模式,就把COLLECT去掉,并把a.binaries、a.datas直接传给EXE,同时exclude_binaries=False。改结构时最容易漏的就是这两处联动,只删 COLLECT 不传参,产物会缺依赖。
3.2 datas、hiddenimports、binaries 三个字段怎么填
datas是元组列表,第一个元素是源路径(相对 spec 所在目录),第二个是包内目标目录。目录名写"."表示放在根,写"assets"表示放在assets/下。常见错误是把文件写成("config.yaml", "config.yaml"),结果它变成了一个叫config.yaml的目录,运行期打开文件直接报 IsADirectoryError。
hiddenimports解决的是“代码里没有字面量 import”的场景。典型有三类:用importlib.import_module(name)按字符串加载、通过__import__动态拼模块名、以及插件式架构靠 entry_points 发现的子模块。这类引用静态分析看不见,产物里就没有对应文件,运行期才炸。判断方法很直接——把程序在打包后的环境里跑一遍所有功能分支,哪一步报 ModuleNotFoundError,就把模块名补进去。批量补的话,collect_submodules("包名")比手写清单省事,但它会把测试子模块也拖进来,需要配合excludes收一收。
binaries用于非 Python 的共享库,比如某个算法 SDK 提供的.dll或.so。格式同样是元组,但第二个元素是目标目录而不是文件名。这类库通常还有依赖链,只拷主库不拷依赖,运行期会报“找不到指定的模块”,排查时用依赖查看工具把整条链拉出来一次配齐。
3.3 资源路径:sys._MEIPASS 与可写目录的分工
资源定位是打包后最高频的翻车点。开发时用Path(__file__).parent / "assets"一切正常,打包后__file__指向的可能是归档内的虚拟路径,文件根本不在那儿。PyInstaller 提供了sys._MEIPASS,在 onefile 模式下指向临时解压目录,在 6.x 的 onedir 模式下指向_internal/。
import sys from pathlib import Path def resource_path(rel: str) -> Path: """只读资源定位:兼容源码运行与打包运行""" base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) return base / rel def writable_dir() -> Path: """可写目录定位:配置、日志、缓存必须落在这里""" if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent # 用法 icon = resource_path("assets/app.ico") # 打包进去的只读文件 log_dir = writable_dir() / "logs" # 运行期写出的文件 log_dir.mkdir(exist_ok=True)两个函数的分工要记牢:_MEIPASS下的内容是只读的,onefile 模式下程序退出就删,往里写文件轻则丢失、重则因为权限报错;sys.frozen为真说明是打包运行,此时用sys.executable的父目录作为可写位置,onedir 模式下这就是程序所在目录,onefile 模式下是用户放 exe 的地方。如果程序装在 Program Files 这类受保护路径下,写同级目录仍会失败,那就改用用户目录下的应用数据路径,这是部署到企业环境时必须考虑的一层。
4. 体积与启动速度:onefile 到底该不该用
4.1 excludes 与 UPX:省下来的体积从哪儿还回去
一个带 GUI 的小工具打出来七八十兆很正常,因为解释器加基础库就有这个量级。压缩体积有两条路,副作用完全不同。
第一条是excludes,把确定用不到的模块从分析图里剪掉。常见的可剪对象包括测试框架、交互式环境、绘图库的多个后端、以及只在开发期用的类型检查工具。这项操作没有运行时开销,纯赚,但要小心连带:某个库可能在异常处理分支里 try-import 了被排除的模块,剪掉后功能会静默降级。稳妥做法是一次只加一个,跑一遍全功能回归。
第二条是 UPX 压缩,upx=True配合环境里存在 upx 可执行文件才会生效。它能把二进制压小三四成,代价有两个:启动时要解压,冷启动变慢;压缩后的二进制特征更容易被某些杀毒引擎判为可疑,误报率明显上升。我的取舍是——内部工具可以开,对外分发默认关掉,体积换来的信任成本不划算。
顺带一提strip,在 Linux 和 macOS 上剥离符号表能省几个兆,Windows 下这个参数无效,别指望它。
4.2 onedir 与 onefile 的选型对照
这个选择没有标准答案,取决于分发方式和用户容忍度。把差异列成表更直观:
| 维度 | onedir | onefile |
|---|---|---|
| 冷启动速度 | 快,直接加载 | 慢,每次解压到临时目录 |
| 分发形态 | 一个目录,通常需再封装安装包 | 单个文件,拷贝即用 |
| 文件数量 | 多,依赖在_internal/ | 1 |
| 杀毒误报 | 相对低 | 相对高 |
| 升级方式 | 整目录替换 | 替换单文件 |
| 调试难度 | 低,文件都在 | 高,临时目录用完即删 |
我的默认选择是 onedir 加一层安装包封装。用户看到的是一个安装程序,实际落地的是目录结构,既拿到了快启动,又避免了“一个 exe 里塞所有东西”的解压开销。只有在分发渠道受限、必须单文件投递时才用 onefile,并且要接受首启三到十秒的延迟。
4.3 启动慢的定位:用 --debug=imports 找元凶
“打包后启动要八秒”这种问题,靠猜没用。PyInstaller 的引导程序支持在启动时打印导入耗时:
# 打开导入日志,观察哪些模块拖慢了启动 pyinstaller --debug=imports --log-level DEBUG --noconfirm --onedir main.py # 运行产物,把引导阶段日志重定向出来 ./dist/demo_app/demo_app > boot.log 2>&1日志里会按顺序列出每个被导入的模块和时间点。常见的慢源有三类:一是重量级的科学计算库在 import 阶段就初始化线程池;二是某个库在导入时扫描字体或证书目录;三是 onefile 的解压本身。前两类可以在业务代码里改成延迟导入——把import pandas从模块顶层挪进真正用到它的函数里,启动阶段就不会触发。第三类只能靠换 onedir 解决。
还有一个容易被忽略的点:--clean只影响构建阶段,不影响运行阶段。有人以为加上它能让程序启动变快,实际是两回事。
4.4 从可执行目录到安装包还差哪几步
严格说,PyInstaller 产出的是可执行文件或目录,不是安装包。要变成用户双击安装、出现在开始菜单、能卸载的东西,还得再套一层封装。这一步可以用任意支持“打包目录为安装程序”的工具完成,流程大同小异:
- 先用 onedir 模式打出
dist/demo_app/完整目录; - 在封装工具里把整个目录设为“递归包含”,主程序指向
demo_app.exe; - 配置安装路径、开始菜单快捷方式、卸载入口;
- 写入版本号与发布者信息,和 exe 的版本资源保持一致;
- 生成安装程序后,在一台没有 Python 的干净机器上完整走一遍安装、运行、卸载。
第 4 步的版本信息很有价值。PyInstaller 支持用--version-file把版本资源写进 exe,格式是特定的文本结构;也可以先用它自带的抓取工具从任意现成 exe 导出一份模板,再改字段。版本号对不上时,企业环境里的软件资产统计会漏掉你的程序,后续升级推送也会出问题。
5. 打包避坑排查:五条踩过的记录
5.1 现象:开发机能跑,同事双击被杀毒直接删掉
原因通常不是真有毒,而是引导程序的行为特征——自解压、写临时目录、加载未签名二进制——在启发式引擎眼里和恶意程序高度相似,UPX 压缩会进一步放大这个相似度。
解决分三层:先关掉 UPX,这一条能消掉大半误报;再给 exe 加代码签名,有证书的情况下效果最明显;最后在分发说明里写清楚文件哈希和来源,让接收方可以自行核对。如果仍然被拦,把产物改成 onedir 加安装包封装,安装程序的白名单通过率通常高于裸 exe。
5.2 现象:本地运行正常,打包后启动即报 ModuleNotFoundError
原因是模块通过字符串动态导入,静态分析没有扫到,产物里压根没这个文件。插件式架构、ORM 的模型自动发现、CLI 框架的子命令注册都属于这一类。
解决方式是先用--debug=imports跑到报错那一步,拿到确切模块名,再补hiddenimports。批量场景用collect_submodules("包名"),但要注意它会带进测试模块,配合excludes剪掉。补完之后不要只看能不能启动,把所有功能入口点一遍,动态导入往往分布在多个分支里。
5.3 现象:带多进程的程序打包后无限开窗口,任务管理器里几十个进程
原因是 Windows 下创建子进程会重新执行入口脚本,而打包后的引导程序没有python.exe这个宿主,multiprocessing的启动逻辑会再次走到打包入口,形成递归。
解决只有一行,但位置要求极严:
import multiprocessing if __name__ == "__main__": multiprocessing.freeze_support() # 必须是主块里的第一句 main()freeze_support()必须在if __name__ == "__main__":之下,且在任何业务初始化之前调用。放到main()里面、或者放在模块顶层,都等于没写。Linux 上默认的 fork 启动方式不触发这个问题,所以“我在 Linux 上测没问题”不能作为依据。
5.4 现象:路径里带中文或空格,程序启动后读不到配置文件
原因有两层:一是打包后的工作目录不是 exe 所在目录,用相对路径open("config.yaml")会去别处找;二是某些底层库对非 ASCII 路径处理不完整。
解决是把所有资源访问统一走resource_path(),把所有写操作统一走writable_dir(),代码里禁止出现裸相对路径。路径拼接一律用pathlib,不要手工拼字符串。做完这两条之后,中文路径问题基本只剩第三方库的锅,那时候要考虑的是在安装阶段就约束安装路径,而不是在代码里打补丁。
5.5 现象:图标不生效、文件属性里没有版本号、右键菜单显示“未知发布者”
图标不生效多半是格式问题——必须是真正的.ico容器,把 png 直接改后缀不行,且需要包含多种尺寸。版本号缺失是因为没有传--version-file,或者传了但字段结构不合法,构建时被静默忽略。
解决方式是先用工具从任意现成 exe 导出一份版本资源模板,改掉公司名、产品名、版本号四个字段,再传给构建命令。图标用图像工具导出多尺寸 ico,16/32/48/256 各一份。这两项做完之后,产物在系统里的观感会从“来路不明的程序”变成“正经软件”,对内部推广的帮助比想象中大。
6. 交付前验证:在干净机器上跑通再发出去
6.1 三台机器的验证清单
开发机上跑通不算数,因为你的机器什么都有。我一般至少准备三类环境:一台从没装过 Python 的干净系统,验证依赖是否真的收全;一台装过旧版本 Python 的机器,验证 PATH 上存在其他解释器时不会串味;一台分辨率与语言环境都不同的机器,验证界面和路径处理。每台机器上固定走一遍这几步:全新安装、启动到主界面、跑一次完整业务流程、导出一次文件、卸载后检查残留目录。
这一步最容易被跳过,因为“在我这好好的”这句话太有安全感了。真正出问题的往往就是那台没装过任何开发工具的办公机——缺运行库、缺字体、组策略限制写临时目录,全是本地复现不出来的场景。
6.2 一个十行的依赖自检脚本
在程序里留一个隐藏的自检入口,出问题时让用户跑一下,比来回问“你那边什么环境”高效得多:
import importlib, platform, sys def self_check(): print(f"python={sys.version.split()[0]} frozen={getattr(sys, 'frozen', False)}") print(f"platform={platform.platform()} arch={platform.machine()}") for name in ("yaml", "PIL", "numpy"): # 换成你的关键依赖 try: mod = importlib.import_module(name) print(f"OK {name} {getattr(mod, '__version__', '?')}") except Exception as exc: print(f"FAIL {name} {exc!r}")这段脚本的价值在于把“打不开”变成一行可读的结论:是依赖没进包,还是进了但版本不对,还是加载时缺底层库。配合第 5 章的排查思路,能省掉大量往返沟通。
打包这件事,真正决定成败的不是命令写得多漂亮,而是你有没有在交付前把别人机器上的那条路径自己走一遍。从那以后我每次出包,都会在一台干净虚拟机上从安装到卸载完整走一遍,哪怕只是改了一个图标。希望帮到你。
本文还有配套的精品资源,点击获取