PyInstaller打包工具2.0
那天晚上我正准备关电脑回家,测试小姑娘在群里敲了句:”东哥你那个脚本能不能给我们一个.exe版本啊,别再让我装Python了…“ 我当时脑子一抽就回了句:”小事,用 PyInstaller 打包下就行“,结果这一打包,直接把我从下班打包到了加班。
先说那个最土但最好用的场景,你有个app.py,里边就这么几行破代码:
# app.py
import timedefmain():
print("运维小工具启动了...")
time.sleep(1)
print("干完活我要下班了")
if __name__ == "__main__":
main()
你在命令行里装一下:
python -m pip install pyinstaller
然后在app.py这个目录里跑一句:
pyinstaller -F app.py
理论上啊,dist目录里就多了个app.exe,丢给测试点两下,世界和平。 结果现实是——测试跟我说:“打不开,闪一下就没了。” 我人都麻了,这不就是传说中的“PyInstaller打包2.0时代”么,从“能跑”升级到“别人也能跑”。
这时候第一个坑就来了:环境乱。 你们知道吧,公司里那种装了三四个 Python 版本的电脑,pyinstaller 到底是哪个环境的,谁知道啊。 我的习惯是直接搞个隔离小黑屋:
python -m venv venv
# Windows
venv\Scripts\activate
# 然后在这个虚拟环境里
python -m pip install pyinstaller
python -m pip install -r requirements.txt
这样打出来的 exe 基本就和这个虚拟环境「绑定」了,少踩一半的坑。上次我写 Postgres 那篇也是同样思路,环境先搞干净再谈性能,结果有人说我啰嗦……
说回打包。上面那个-F,其实就是“全塞一个文件里”的意思,图省事肯定要加,不然 PyInstaller 默认给你搞一大堆乱七八糟的文件,一个dist/app/目录,别人一看:这啥玩意我不敢点。
但你要是带界面的,比如 PyQt、Tkinter,命令基本就变成这样了:
pyinstaller -F -w app.py
-w就是“windowed”,粗暴理解成:不要黑乎乎的控制台。 我第一次忘记加这个参数,给产品经理发了个 exe,他点开后弹出一个控制台,里面刷刷打印日志,跟我说:“这个像黑客电影,我有点害怕…” 我:……
更恶心的是带资源文件的那种,比如你有个config.yaml,还有个logo.png,平时跑的时候你这么写:
import json
from pathlib import PathBASE_DIR = Path(__file__).parent
defload_config():
cfg_path = BASE_DIR / "config.json"
with cfg_path.open("r", encoding="utf-8") as f:
return json.load(f)
本地跑没问题,一打包就报:“找不到 config.json”。 原因其实就是——打包完之后,__file__不再是在你想象的那个目录里了,PyInstaller 会把东西塞到一个临时目录去。
这时候就得来点“打包2.0级别”的写法了,搞个专门算资源路径的小函数:
import os
import sys
from pathlib import Pathdefreal_path(rel: str) -> Path:
# PyInstaller 打包后会塞到 _MEIPASS 这个临时目录
if hasattr(sys, "_MEIPASS"):
base = Path(sys._MEIPASS)
else:
base = Path(__file__).parent
return base / rel
if __name__ == "__main__":
cfg = real_path("config.json")
print("配置文件在:", cfg)
然后打包的时候要把资源一起带上,不带它肯定找不到嘛,比如:
pyinstaller -F app.py --add-data "config.json;."
Windows 下中间是分号;,Linux/mac 下是冒号:,这个地方我每次都要翻一下历史命令,不然必输错。
说完资源,讲一个很多人不注意但是超级致命的点:打包的时候一定要在命令行里跑 exe 看日志。 你如果是双击 exe 启动,闪退只会给你留一句“打开失败”的眼神。 在 CMD 里这样:
cd dist
app.exe
这时候如果代码里有异常,就能直接看到 Traceback 了。 我一般还会加点粗暴的兜底日志,万一出问题至少知道挂哪了:
import traceback
from datetime import datetime
from pathlib import Pathdefmain():
# 正常逻辑
...
if __name__ == "__main__":
try:
main()
except Exception as e:
log = Path("error.log")
with log.open("a", encoding="utf-8") as f:
f.write("\n==== {} ====\n".format(datetime.now()))
traceback.print_exc(file=f)
# 你爱弹窗就弹窗,爱print就print
print("程序崩了,详情看 error.log:", e)
raise
很多兄弟打包完扔给用户,用户说“点不动”,然后人人怀疑 PyInstaller 有 bug。 结果最后发现是自己代码里open("xxx.txt")硬编码路径,或者随手写了个time.sleep(99999)在主线程里,怪谁嘛。
再往后一点就是“hook”的世界了,这个就算 PyInstaller 2.0 的高端玩法吧。 有些三方库是动态 import 的,比如你用了某个插件系统,运行时才__import__; PyInstaller 静态分析的时候根本分析不到,就会导致:打包成功,运行时报ModuleNotFoundError。
这时候你可以自己写个简陋版 hook,比如新建hook-myapp.py:
# hook-myapp.py
hiddenimports = [
"myapp.plugins.exporter_excel",
"myapp.plugins.exporter_pdf",
]
然后在打包的时候告诉 PyInstaller 去找你这个 hook:
pyinstaller -F app.py --additional-hooks-dir=.
当然了,如果你不想研究 hook,最土的办法就是在代码里“假装用一下”这些模块:
# somewhere at startup
import importlibfor name in [
"myapp.plugins.exporter_excel",
"myapp.plugins.exporter_pdf",
]:
try:
importlib.import_module(name)
except ImportError:
# 有些环境可能没装,忽略
pass
这样 PyInstaller 至少能在静态分析阶段把它们捞进去,不至于打包缺胳膊少腿。
还有一个经常被喷的点:exe 很大。 第一次打包完一看 80MB,测试说:“你这是一个 hello world 还是一个游戏安装包?” 这个其实跟你用了多少三方库、是不是单文件、有没有带一堆没用的东西都有关系。 我一般会做两件事:
能不用大型库就不用,比如一个简单的请求,用 httpx也行,非得上一个超大的全家桶;实在受不了体积,就先别 -F,用目录模式,体积小一点,但要教用户点app.exe,别点别的。
还有,有些老机器会报各种 msvcp140.dll 缺失之类的,这种就别太纠结 PyInstaller 了,属于系统环境问题,给对方一个 VC++ 运行库安装包基本就好了。
说了这么多,你会发现 PyInstaller 这个东西,本身其实就几条命令:-F、-w、--add-data、虚拟环境、偶尔搞个 hook,就够用了。 麻烦的是各种“场景”:别人电脑没 Python、权限乱七八糟、路径里有中文、杀毒软件看你不顺眼…… 所以我现在的打包心路也从 1.0 升级到 2.0 了:
自己能跑不算完事,要假装自己是那个什么都不会的测试同学; 能在干净环境(甚至裸机)跑通一次,再敢说“打包OK”; 任何看起来“玄学”的问题,先在命令行里跑 exe,看清楚报错再骂 PyInstaller。
行了不说了,我这边又有人在群里叫我帮忙“打个包”, 等我先看一眼他是不是又在 Windows 桌面上建了个中文路径的项目……