Python exe文件打包神器-Nuitka!
昨晚十一点多吧,我在公司楼下抽烟,手机震个不停,我们组那个小李在群里嚷嚷:“哥,那个 Python 写的工具给客户了,人家一双击就报一堆缺库,能不能整成 exe 啊,我不想教他装环境了……”我当时困得要死你们懂吧,但还是回了句:你别折腾 pyinstaller 那套先,真的,先试试 Nuitka。
就这玩意儿吧,说白了它不是“把脚本塞进一个壳里”,它更像是把你的 Python 代码编译成 C/C++ 再编译成机器码,所以很多时候跑起来更像“正常程序”,启动也没那么肉(当然也不是所有项目都变快,别抬杠哈)。我第一次用它是给现场产线一个小工具打包,那种电脑一堆管控,啥都不让装,最后就想要一个 exe,双击就干活,别问,问就是……当时我也被逼急了。
你们先别急着上命令,我先把我当时踩的坑说下,免得你们又半夜来敲我。Nuitka 最常见的坑就是:你代码里要是有动态 import,或者插件式加载,Nuitka 可能“看不到”,它就不打进去,然后你运行 exe 的时候才炸。还有就是数据文件,比如模板、配置、图标,别想着它会自动带上,得显式说。还有一个更阴间的:有些公司电脑杀软会对“打包程序”天然敏感,你生成的 exe 可能被隔离,这个不是你写错,是环境太野,得白名单或者签名,反正你懂的。
我拿一个特别接地气的小例子说吧,就写个“日志小助手”,运行后把当前目录里所有 .log 文件扫一遍,统计 ERROR 次数,顺便写一个报告 txt。你就当你给测试同学用的,省得他们装 Python。
先上代码,放一个 app.py:
# app.py
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from datetime import datetime
ERROR_RE = re.compile(r"\bERROR\b", re.IGNORECASE)
@dataclass
classFileStat:
file: str
error_count: int
lines: int
defscan_log_file(p: Path) -> FileStat:
# 有些日志是 gbk/utf-8 混着来,这里就别太“洁癖”
raw = p.read_bytes()
text = None
for enc in ("utf-8", "gbk", "utf-8-sig"):
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if text isNone:
# 实在不行就硬来,别崩
text = raw.decode("utf-8", errors="ignore")
lines = text.splitlines()
err = sum(1for line in lines if ERROR_RE.search(line))
return FileStat(file=p.name, error_count=err, lines=len(lines))
defgenerate_report(stats: list[FileStat], out: Path) -> None:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
total_err = sum(s.error_count for s in stats)
total_lines = sum(s.lines for s in stats)
parts = []
parts.append(f"报告时间:{now}")
parts.append(f"文件数:{len(stats)}")
parts.append(f"总行数:{total_lines}")
parts.append(f"ERROR 总数:{total_err}")
parts.append("")
parts.append("逐文件明细:")
for s in sorted(stats, key=lambda x: (-x.error_count, x.file)):
parts.append(f"- {s.file}: ERROR={s.error_count}, lines={s.lines}")
out.write_text("\n".join(parts), encoding="utf-8")
defmain() -> int:
cwd = Path.cwd()
logs = sorted(cwd.glob("*.log"))
ifnot logs:
print("当前目录没找到 .log 文件,我先歇了……")
return1
stats = [scan_log_file(p) for p in logs]
out = cwd / "report.txt"
generate_report(stats, out)
print(f"搞定了,报告在:{out}")
return0
if __name__ == "__main__":
raise SystemExit(main())
这段代码我故意写得“脏”一点点,比如编码兜底,不然你打成 exe 发给别人,别人日志里来个 gbk,你在那边“优雅报错”,人家只会骂你。对吧。
然后你想让它更像个小软件,可以顺手加个版本号文件,比如 meta.py:
# meta.py
APP_NAME = "LogBuddy"
VERSION = "0.1.0"
这里就引出一个点:你如果后面想在 exe 里显示版本信息,或者做 --version 参数,最好不要到处硬编码。比如改成这样:
# app.py 里补一段
from meta import APP_NAME, VERSION
# main() 开头加一个简单参数处理
import sys
defmain() -> int:
if len(sys.argv) > 1and sys.argv[1] in ("-v", "--version"):
print(f"{APP_NAME}{VERSION}")
return0
...
好,到了打包这一步,Nuitka 的思路是:你告诉它入口脚本,然后选“单文件”还是“目录分发”。我一般不迷信 onefile,因为 onefile 启动要先解压一堆东西到临时目录,环境差的机器上,启动会慢一截,而且杀软更爱盯着这种行为。但客户就喜欢“一个 exe”,没办法,那就 onefile。
你在项目目录里,装一下 Nuitka(这一步就不展开了,懂的都懂),然后打包时我常用的参数大概是这种感觉(我不贴网址哈,免得平台又敏感):
# 你就当这是“命令备忘录”,复制到记事本里执行
BUILD_CMD = r"""
python -m nuitka app.py ^
--onefile ^
--standalone ^
--assume-yes-for-downloads ^
--output-filename=LogBuddy.exe ^
--enable-plugin=multiprocessing ^
--nofollow-import-to=tkinter
"""
print(BUILD_CMD)
为啥我把命令塞 Python 字符串里?因为我经常在群里发,直接一段,别人在 Windows 上复制粘贴不至于乱七八糟……你们也别笑,我真就这么干过。里面几个点你得有感觉:
--standalone:依赖都带上,不然你 onefile 也可能缺东西。--enable-plugin=multiprocessing:你项目要是用了多进程,不开这个,迟早出幺蛾子。--assume-yes-for-downloads:有些编译链它会提示下载组件,你在 CI 里不想卡住就加。--nofollow-import-to=tkinter:这个纯属示例,意思是“别把某个你根本不用的东西拖进来”,能瘦一点点。
然后数据文件怎么搞?比如你有个默认配置 default.conf,你就得显式包含,不然 exe 里找不到。你可以这么写(同样我还是用 Python 字符串当备忘录):
BUILD_CMD_WITH_DATA = r"""
python -m nuitka app.py ^
--standalone ^
--onefile ^
--include-data-file=default.conf=default.conf ^
--output-filename=LogBuddy.exe
"""
print(BUILD_CMD_WITH_DATA)
注意啊,左边是你本地文件路径,右边是打包后在运行目录里呈现的相对路径。这个地方最容易写反,然后你还在那怀疑人生,说“我明明带了文件啊”,结果路径不对,笑死。
还有一个我自己很常干的事:写一个小自检,打包前跑一次,打包后跑一次,避免你发出去才发现崩。比如 smoke_test.py:
# smoke_test.py
import subprocess
from pathlib import Path
defrun(cmd: list[str]) -> int:
p = subprocess.run(cmd, capture_output=True, text=True)
print(p.stdout.strip())
if p.returncode != 0:
print("stderr:", p.stderr.strip())
return p.returncode
if __name__ == "__main__":
# 1) 源码运行
print("== python run ==")
run(["python", "app.py", "--version"])
# 2) exe 运行(假设在当前目录)
exe = Path("LogBuddy.exe")
if exe.exists():
print("== exe run ==")
run([str(exe), "--version"])
else:
print("没找到 exe,先把 Nuitka 跑完再测")
你别小看这个自检哈,很多“我电脑能跑你电脑不能跑”的破事,都是你缺了一个最小验证闭环。尤其是你项目里带了第三方库的时候,什么 requests、pandas,你只要有一个隐藏依赖没打进去,客户那边就炸,你还复现不了,能把人搞疯。
最后再说个现实问题:Nuitka 打出来的东西,有时体积不小。你要是带上科学计算那套(numpy、pandas),体积几十上百 MB 也正常,别在群里装惊讶。你要真想瘦,思路就两个:一是减少依赖,二是让它别把你不用的模块拉进来。还有就是,能不用 GUI 就别用 GUI,GUI 相关的依赖链一拉就肥。
行了,我先说到这吧,我这会儿正准备去会议室,领导又在催那个“交付包怎么还没给客户”,我人都麻了……你们要是打包过程中遇到那种“运行就闪退”的,别急着重装系统,先把 --show-progress、--show-memory 这些开起来看日志,等下,我电梯到了,先不聊了。