简介:Python打包工具是一套面向开发者的图形化封装方案,在PyInstaller命令行能力之上提供可视化界面,帮助不熟悉命令参数的初学者把脚本连同解释器、标准库与第三方依赖封装为可独立运行的可执行文件或目录,使程序在未安装Python环境的机器上也能直接分发运行。压缩包共416个文件、约747KB,以202个css样式、152个png界面图片、27个js脚本为主体,可见主体是一套EasyUI风格的界面资源,另含少量py与pyc脚本、txt说明及xml配置,承载打包逻辑与操作指引;在界面中填写脚本路径、选择单文件或单目录模式、指定输出位置,即可完成依赖分析与模块裁剪。已有2455人学习。包内示例.png与结果.png对照展示操作界面与打包产物,说明.txt汇总使用方法、注意事项与常见问题,便于按图验证产物能否正常运行。
1. 一个 .py 发出去对方双击没反应:先搞清楚 python 打包工具在解决哪一层问题
上周有同事把一份数据清洗脚本发给业务方,对方双击之后窗口闪一下就没动静了。远程连过去看,机器上压根没装 Python;换一台装了 Python 的,又弹出ModuleNotFoundError: pandas。这类事在内部工具交付里反复出现,python 打包工具要解决的正是这一层问题——把解释器、第三方依赖、入口脚本和资源文件捆成一个目标机器能直接运行的产物。它不提升运行性能,也不改代码逻辑,只负责把“在我机器上能跑”变成“在别人机器上也能跑”。
适合往下看的人很明确:需要把脚本交给不装 Python 的同事、要给客户交付一个桌面小工具、要在 CI 里产出固定版本的可执行文件。这里先立一个前提:python 打包 编译这件事没有一键方案,选错路线比不会用工具更浪费时间。后面按“冻结还是编译”的选型、PyInstaller 的 spec 改造、Nuitka 的编译参数、五类翻车现场,一路落到交付前的验证动作。
2. 三条路线先分清:冻结、编译、自带解释器各自的边界在哪
2.1 冻结式打包:PyInstaller 运行时到底发生了什么
PyInstaller 的Analysis阶段做三件事:从入口脚本出发静态扫描 import 语句,递归构建模块依赖图;把命中的纯 Python 模块编译成.pyc塞进 PYZ 归档;把扩展模块(Windows 上是.pyd,Linux 上是.so)和声明的数据文件挑出来放进 COLLECT 目录。随后它生成一个叫 bootloader 的 C 小程序,把所有内容追加在这个程序后面,产出一个能独立启动的 exe。
运行时发生的事情才是关键:bootloader 起来后,onefile 模式会先把归档解压到系统临时目录下的一个_MEIxxxxx文件夹,把该目录写进sys.path,再调用 Python 解释器执行入口脚本;onedir 模式直接从_internal目录读,不解压。这也解释了后面两个高频坑:为什么 onefile 首次启动慢,以及为什么运行时要写临时文件。
静态扫描有个天然盲区。凡是importlib.import_module(name)、__import__("pkg." + sub)、插件的 entry_points 自动发现、Django 的INSTALLED_APPS字符串,PyInstaller 一律看不见。这类东西就是hiddenimports存在的理由,不是玄学,是扫描器看不到。
2.2 五种常见产物的对比
| 工具 | 原理 | 产物形态 | 启动速度 | 体积 | 源码保护 | 跨平台 |
|---|---|---|---|---|---|---|
| PyInstaller | 冻结字节码 + bootloader | 单文件或目录 | onefile 首启慢,onedir 快 | 中到大 | 弱,.pyc可还原 | 不能交叉编译 |
| cx_Freeze | 冻结 + 自带解释器 | 目录为主 | 快 | 中等 | 弱 | 不能交叉编译 |
| Nuitka | 翻译成 C,交给系统编译器 | 二进制 + 目录 | 快 | 依赖裁剪,常小于冻结 | 强(仍是二进制) | 需目标平台编译 |
| shiv / pex | 生成带引导的 zipapp | 单个.pyz | 中等 | 小 | 无 | 目标机需 Python |
| 标准库 zipapp | 打包成 zip 加 shebang | .pyz | 中等 | 最小 | 无 | 目标机需 Python |
体积一栏要补一句实话:体积主要跟依赖走,工具只是次要因素。见过一个项目为了“减小体积”从 PyInstaller 换到 Nuitka,结果 numpy、scipy、torch 一进来,体积照样是 2 GB 以上。体积的账先算依赖树,再算工具。
2.3 一次最小可跑的冻结
# 目录结构 # demo/ # app.py # requirements.txt python -m venv .venv source .venv/bin/activate # Windows:.venv\Scripts\activate pip install -r requirements.txt pip install pyinstaller # 先出目录版,好排查;确认没问题再考虑单文件 pyinstaller --noconfirm --clean --onedir --console --name demo app.py # 产物在 dist/demo/ 下,直接运行 ./dist/demo/demo参数逐项说明:--onedir生成目录结构,启动快、缺失文件好补,调试阶段一律用它;--onefile生成单个可执行文件,代价是每次启动都要解压到临时目录;--console保留控制台,报错能看见,图形界面程序调试完再换--windowed;--clean清掉 PyInstaller 的缓存,避免改了代码但产物没变的吊诡现象;--name决定产物名,建议和入口脚本同名,便于 CI 里定位。
注意:务必在干净的虚拟环境里打包。用系统 Python 或者一个装了几百个包的环境去打包,PyInstaller 会把能扫到的包全拉进来,产物从 80 MB 变成 800 MB,你就得花一晚上做减法。
2.4 怎么根据项目特征选路线
第一个判断是目标机器上有没有 Python。有 Python 且是内网 Linux 服务,shiv 或 zipapp 最省事,几秒钟出一个单文件,体积只有几百 KB;没有 Python,才轮到冻结和编译。
第二个判断是有没有重量级二进制依赖。numpy、opencv、PyQt、torch 这类带.pyd、.so和大量数据的包,走 PyInstaller 的 onedir 最稳,官方 hook 覆盖得比较全,缺什么补什么。Nuitka 也能做,但每个二进制依赖都可能需要插件显式声明,第一次跑通的时间成本更高。
第三个判断是源码要不要保护。.pyc有现成的反编译工具,把 pyc 丢进去基本能还原出可读代码,所以有商业授权逻辑、有密钥硬编码的脚本,冻结方案只是“打了一层雾”。真要挡一下,只能走编译路线,而且要把敏感常量从代码里挪出去。
第四个判断是交付形态。业务方一般只接受单文件,那你得接受 onefile 的启动延迟和临时目录行为;运维同事能接受目录,onedir 就是更优解,升级时还能只替换改动的文件。
3. PyInstaller 的 spec 文件:hiddenimports 与资源路径的一次配对
3.1 先让 spec 文件生成出来,再改它
命令行敲得再长,最终也会被 PyInstaller 翻译成一份.spec文件。与其每次拼参数,不如直接pyi-makespec生成模板再改,把它纳入版本管理。CI 里执行的命令就变成一行pyinstaller demo.spec,参数差异全在文件里,可 review、可回溯。
# 生成 spec 模板,先不要直接打包 pyi-makespec --onedir --console --name demo app.py # 改完 spec 后的构建命令 pyinstaller --noconfirm --clean demo.spec为什么推荐这条路径而不是纯命令行:hiddenimports、datas、excludes这三个清单一旦超过三项,命令行会变成无法维护的超长字符串,换个人接手只能靠猜。spec 是 Python 文件,可以写循环、读环境变量,比如按sys.platform决定加不加某个平台专属数据目录,这在命令行里做不到。
3.2 Analysis 段的 hiddenimports 什么时候必须手写
# demo.spec a = Analysis( ['app.py'], pathex=[], binaries=[], # datas 的每一项是 (源路径, 打包后在产物内的相对目录) datas=[('assets/config.yaml', 'assets'), ('assets/icons', 'assets/icons')], # 扫描器看不见的动态导入,全部写死在这里 hiddenimports=[ 'pkg_resources', # 部分老库运行时才 import 'charset_normalizer.md__mypyc', # 该包按平台动态选后端 'app.plugins.csv_loader', # 本项目插件机制,字符串导入 ], hookspath=['hooks'], # 自定义 hook 放这里 runtime_hooks=[], excludes=['tkinter', 'matplotlib.tests', 'numpy.tests'], noarchive=False, )hiddenimports的填法有讲究:不要凭猜,直接看报错。打包后运行到ModuleNotFoundError: No module named 'xxx',把xxx加进去重新打包,迭代两三轮就收敛了。项目自己的插件目录可以一次性全列出来,写成列表推导:
# 扫一遍插件目录,把模块名转成点号路径,避免手工漏项 import pathlib plugin_dir = pathlib.Path('app/plugins') PLUGINS = [f'app.plugins.{p.stem}' for p in plugin_dir.glob('*.py') if not p.name.startswith('_')] a = Analysis( ['app.py'], datas=[('assets/config.yaml', 'assets')], hiddenimports=PLUGINS + ['pkg_resources'], excludes=['tkinter', 'matplotlib.tests'], noarchive=False, )这段写法的适用边界是app本身是包且有__init__.py;如果插件是运行时从外部目录加载的,那不该进hiddenimports,而应该改成随包分发的 datas,让用户能自己放插件。
3.3 datas 与运行时路径:sys._MEIPASS 的正确写法
打包后最常见的翻车是“资源文件找不到”。源码里写open('assets/config.yaml'),依赖的是当前工作目录;打包后工作目录变成了用户双击的目录,路径必然错。正确做法是相对“产物所在位置”定位。
import sys from pathlib import Path def resource_path(rel: str) -> Path: """定位只读资源:兼容源码运行、PyInstaller onefile/onedir、Nuitka standalone。""" base = getattr(sys, "_MEIPASS", None) if base is None: # Nuitka 和源码运行都走这里,用入口文件所在目录 base = Path(sys.argv[0]).resolve().parent if getattr(sys, "frozen", False) \ else Path(__file__).resolve().parent return Path(base) / rel def user_data_path(name: str) -> Path: """定位可写目录:绝对不能写进资源目录,onefile 下那是临时解压区。""" p = Path.home() / ".demo_app" p.mkdir(parents=True, exist_ok=True) return p / name cfg = resource_path("assets/config.yaml") db = user_data_path("cache.db")三个要点:sys._MEIPASS是 PyInstaller 注入的解压目录,onedir 模式较新的版本也提供了它,指向_internal;sys.frozen是判断“是否处于打包环境”的通用标记;可写文件必须落到用户目录,写进_MEIPASS在 onefile 下程序退出就被清掉,是典型的“本地跑好好的、交付就丢数据”。
3.4 裁剪体积的三个开关:excludes、UPX、--onedir
excludes是性价比最高的一项。tkinter 动辄三四十 MB,matplotlib 的测试数据和示例能占上百 MB,这些在Analysis里排除掉不影响主功能。做法是打包后翻一遍build/demo/Analysis-00.toc,看哪些包体积大、是否真的需要。
UPX 要谨慎。它压缩二进制能省 30%~50%,但对某些扩展模块(尤其是加密库、图形库)压缩后会出现加载失败或运行崩溃,症状非常难查。我的习惯是默认关掉,只在确认体积不可接受、且逐项验证过功能之后,对特定文件单独启用。
--onedir本身也是体积优化手段:启动时不解压,用户感知快;升级时只替换变化文件,分发成本低。只有业务方硬性要求单文件时才换 onefile,并且要提前告知首次启动会慢几秒。
4. Nuitka 编译路线:从 import 到 C 再到可执行文件的参数账
4.1 Nuitka 把 import 翻译成 C:和冻结的根本差别
冻结是把.pyc原样搬运,运行时还是靠 Python 解释器执行字节码;Nuitka 则把 Python 模块翻译成 C 代码,再调用系统上的 C 编译器(Windows 上是 MSVC 或 MinGW64,Linux 上是 gcc)编译成机器码,最后链接成一个可执行文件。差别体现在三处:反编译难度大幅上升;循环和数值计算类代码有实际提速;产物体积可以被裁剪得更小,因为没被引用的模块根本不会进 C 代码。
代价也很直接:编译时间长,一个中等项目首次编译十几分钟很常见;每个二进制依赖可能需要插件;出问题时的报错是 C 编译器的报错,信息密度低。所以我的顺序是先 PyInstaller 跑通功能,再考虑换 Nuitka 解决源码保护或性能问题,不要一上来就硬啃。
4.2 最小命令与关键参数
# 安装:编译器要提前装好,Windows 建议用 MSVC 或让 Nuitka 自动下载 MinGW64 pip install nuitka ordered-set zstandard # 最小可用:目录版,便于排查 python -m nuitka \ --standalone \ --assume-yes-for-downloads \ --enable-plugin=pyqt5 \ --include-data-dir=assets=assets \ --include-package=app.plugins \ --output-dir=build \ --company-name="Internal Tools" \ --product-name="Demo" \ --file-version=1.0.0.0 \ --windows-console-mode=disable \ app.py逐个解释:--standalone是生成带依赖目录的关键,等价于冻结路线的 onedir;--onefile生成单文件,但同样有解压启动开销,且首次启动比 PyInstaller 更明显;--enable-plugin=pyqt5是显式打开插件,PyQt、PySide、tk-inter、numpy 这些都需要对应插件,否则运行时缺 DLL;--include-data-dir=源=目标对应 PyInstaller 的 datas;--include-package用于整包包含,适合插件目录;--windows-console-mode=disable在较新版本里替代了老的--windows-disable-console;--assume-yes-for-downloads让它在缺 MinGW 时自动下载,CI 里很省事。
Windows 上如果要带版本信息,--file-version必须是四段数字,写成1.0会直接报错,这个坑我踩过。Linux 上跨 glibc 版本分发是个硬约束:在老系统上编译的产物能跑在新系统上,反过来不行,所以 CI 的构建镜像要选目标环境里最老的那个发行版。
4.3 插件与数据目录:什么时候必须显式声明
判断标准很简单:只要某个依赖在site-packages下有自己的数据目录(字体、翻译、Qt 插件、模型文件),就必须显式声明。最省事的兜底是--include-package-data=包名,把整个包的数据目录带进去;更精细的做法是--include-data-dir只挑用到的那几个子目录。
# 常见组合:Qt 界面 + 图标字体 + 一个需要数据文件的库 python -m nuitka \ --standalone \ --enable-plugin=pyqt5 \ --include-package-data=matplotlib \ --include-data-dir=app/resources=app/resources \ --nofollow-import-to=tkinter,unittest,pydoc \ --output-dir=build \ app.py--nofollow-import-to是编译路线的体积开关,等价于excludes,把用不到的包整棵剪掉。测试类、文档工具、备用 GUI 框架都是首选裁剪对象。注意不要剪掉pkg_resources、importlib.metadata这类运行时元数据入口,很多库靠它做版本探测,剪掉之后报错信息非常隐晦。
4.4 编译耗时、缓存与产物体积的账
Nuitka 有编译缓存,放在用户目录下,改了少量代码时二次编译会快很多;CI 里可以把缓存目录做 artifact 缓存,能省一半以上时间。但换编译器、换 Python 小版本、改--enable-plugin列表都会让缓存失效,别指望每次都命中。
体积方面,--standalone产物通常比 PyInstaller onedir 小,因为没被 import 的模块不会被编译进去;但库依赖多的项目(比如同时有 pandas 和 matplotlib)差距会缩小到 10%~20%,别把它当成体积神器。真要压体积,先做依赖审计,把不用的库从requirements.txt里删掉,这比换工具有效一个数量级。
产物的一个隐性差异是启动行为:编译后的程序没有“解压到临时目录”这一步,首次启动比较稳定;但反过来,它对系统运行库的依赖更硬,缺 MSVC 运行库时会直接弹窗报缺少 DLL,而不是像 PyInstaller 那样把问题藏在 Python 层的异常里。所以分发前把 VC++ 运行库一起打包,或者在安装说明里写清前置条件。
5. 排查清单:打包后闪退、缺 DLL、误报的五个现场
5.1 现场一:双击闪退,命令行运行却正常
现象:交付的 exe 双击后窗口一闪就没了,什么提示都没有;在命令行里敲./demo.exe反而能看到完整报错。
原因:--windowed模式下 stdout/stderr 被丢弃,未捕获异常的堆栈全部进了虚空。程序很可能在启动阶段就因为找不到资源文件、配置文件格式错误或者数据库连不上而退出。
解决:调试期一律用--console;必须交付 GUI 版本时,在入口处包一层顶层异常捕获,把 traceback 写到用户目录的日志文件里。
import sys, traceback, datetime from pathlib import Path def main(): # 业务入口 ... if __name__ == "__main__": log = Path.home() / ".demo_app" / "crash.log" log.parent.mkdir(parents=True, exist_ok=True) try: main() except Exception: with log.open("a", encoding="utf-8") as f: f.write(f"\n===== {datetime.datetime.now()} =====\n") f.write(traceback.format_exc()) raise这段代码只在打包环境里需要,代价是几十行,收益是把“闪退”变成“有文件可查”。raise保留是为了让系统事件日志里也有记录,方便现场排查。
5.2 现场二:ModuleNotFoundError 只在打包后出现
现象:源码运行完全正常,打包后一启动就报某个模块找不到,而且这个模块明明装在环境里。
原因:动态导入。典型场景包括importlib.import_module("app.plugins." + name)、SQLAlchemy 的方言加载、Django 的 app 字符串注册、pytest 插件自动发现。静态扫描器看不到字符串拼接出来的模块名。
解决:先看完整报错里的模块名,加进 spec 的hiddenimports;如果是整包动态加载,用--collect-submodules=包名(PyInstaller)或--include-package=(Nuitka)一次性带上。改完重新打包,别在原产物上手动拷文件,否则下次构建又会丢。
5.3 现场三:提示缺少 VCRUNTIME140.dll 或 api-ms-win-crt-*.dll
现象:在一台干净机器上运行,弹窗提示缺少某个 C 运行库 DLL,程序直接起不来。
原因:Python 扩展模块和 Nuitka 产物依赖 MSVC 运行库,目标机器没装 VC++ Redistributable 就会缺。Windows Server 和精简版系统上尤其常见。
解决:三条路选一条——安装包里附带 vc_redist.x64.exe 作为前置步骤;把运行库 DLL 一起放进产物目录(注意授权允许分发的那几个);或者用静态链接运行库的方式编译。我一般选第一条,安装器里判断一下装没装,静默安装,比往产物里塞 DLL 干净。
5.4 现场四:单文件启动要等十几秒,杀软还报毒
现象:onefile 版双击之后,任务管理器里能看到进程,但界面要等很久才出来;部分机器上杀毒软件直接隔离。
原因:onefile 每次启动都要把几百 MB 的内容解压到%TEMP%下的_MEIxxxx目录,机械硬盘上就是很慢;bootloader 的解压加执行行为在启发式引擎里跟自解压木马高度相似,误报是常态。
解决:优先改 onedir,启动立刻变快;确实要单文件就加--runtime-tmpdir指定一个固定可写目录,减少反复解压的随机性。误报没法规避,只能走企业白名单流程,或者做代码签名——签名不能保证不报,但能大幅降低概率,也方便用户判断来源。
5.5 现场五:路径里有中文或空格,程序起不来
现象:程序装在C:\Users\张三\Desktop\工具\下,运行报编码错误或者找不到文件;换成英文路径就正常。
原因:_MEIPASS拼出来的路径包含中文,某些 C 扩展用char*处理路径时按系统编码解释会失败;还有一部分老库在打开文件时没指定encoding,默认走 GBK。
解决:所有open()显式写encoding="utf-8";路径拼接一律用pathlib;交付文档里明确建议安装到纯英文目录。这是最省事也最容易被忽略的一条,写进 README 能省掉大量远程支持。
6. 交付前的干净机器验证:四步自检和一个后悔药
打包能跑不等于交付能用。我的规矩是每次发出前走一遍四步自检,全程不超过二十分钟,能挡掉八成返工。
第一步,找一台没装过 Python、没装过 VC++ 运行库的干净虚拟机,把产物拷进去双击。这一步专门抓“依赖本地环境”的问题,尤其是ModuleNotFoundError和缺 DLL。本机测试永远测不出这类问题,因为你的开发机上什么都有。
第二步,核对依赖树。PyInstaller 看build/demo/Analysis-00.toc,Nuitka 看产物目录里有没有意料之外的包。我一般会搜一下tests、examples、docs这几个关键词,命中就说明有东西被误收进来了,加进excludes重新构建。
第三步,检查元信息。Windows 上看文件属性里的版本号、产品名、图标对不对;macOS 上看 Info.plist 的 bundle id 和最低系统版本。这些不影响功能,但直接影响对方对交付物的信任度,版本号写成空的会显得很草率。
第四步,验证可写路径。手动删掉用户目录下的配置缓存,重新启动,确认程序能自己重建;再手动把程序装到只读目录(比如C:\Program Files\下)跑一次,确认没有往安装目录写任何东西。这一步是 onefile 方案最容易翻车的地方。
| 检查项 | 抓什么问题 | 通过标准 |
|---|---|---|
| 干净虚拟机上运行 | 隐式依赖、缺运行库 | 双击正常启动到主界面 |
| 依赖树核对 | 误收测试包、体积膨胀 | 无tests/examples目录 |
| 元信息检查 | 版本号、图标缺失 | 属性页信息完整 |
| 只读目录运行 | 往安装目录写文件 | 无权限报错,日志进用户目录 |
这里的第四步有个后悔药:在入口脚本最前面加一行环境变量检测,如果发现当前目录不可写,就自动把日志和缓存切到用户目录,并打一条 warning。这行代码我一般和异常捕获写在一起,加起来不到三十行代码,却能挡掉“客户装完打不开、日志都没有”这种最难远程处理的局面。
说个教训。早期我交付过一个内部小工具,本机测了三遍都正常,发出去第二天对方说双击没反应。原因是打包时用的是全局 Python 环境,产物里混进了某个只有我机器上装了路径依赖的库,在干净机器上直接崩在 import 阶段,而--windowed又吞掉了所有错误。从那以后我每次发包前都强制走一遍干净虚拟机加顶层异常日志这两步,无论项目多小。
这套流程跑顺之后,打包本身其实变成了最不费脑的一环,真正花时间的是依赖审计和路径治理。上面用到的 spec 模板、resource_path工具函数、Nuitka 参数组合和自检清单,我都整理成了可直接套用的文件结构,入口脚本、hooks 目录、CI 构建命令都在里面,拿到后把app.py和requirements.txt换掉就能跑起来。希望帮到你。
本文还有配套的精品资源,点击获取