本节目标:把 14.1 写好的 CLI 真正交付出去——理解入口点、用标准库
zipapp打出可直接执行的单文件、踩平相对导入的坑,并看清 PyInstaller 这条路的代价。
适用版本:Python 3.12+(实测 3.14.6);zipapp 为标准库;PyInstaller 本机未安装(未实测)
14.2 分发 CLI:zipapp 与打包
14.1 结束时,textkit 已经能被 python -m textkit 调用。但「能跑」和「能交付」是两回事:用户不想先配环境再 pip install,CI 想要一个能塞进镜像的产物,同事想要一个拷过去就能用的文件。本节把分发的三条路讲清楚,并真打包、真运行。
14.2.1 三种分发形态
| 形态 | 产物 | 依赖前提 | 适用 |
|---|---|---|---|
| 源码 + 入口点 | wheel / sdist | 用户有 Python 与 pip | 开发者、CI |
| zipapp 单文件 | .pyz | 用户有 Python(依赖需自带或已装) | 脚本工具、内部交付 |
| 原生可执行 | 单个二进制 | 无需 Python | 给不懂技术的用户 |
三者不是递进关系,而是依赖环境多少的取舍:越往下,用户侧要求越少,构建侧成本越高。
14.2.2 入口点:console_scripts
最标准的分发是打成 wheel,在 pyproject.toml 里声明一个控制台入口点:
[project]
name = "textkit"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["click>=8.5"]
[project.scripts]
textkit = "textkit.cli:cli"
[project.scripts] 会让安装器在 bin/ 下生成一个 textkit 可执行脚本,它其实就是一层极薄的包装:from textkit.cli import cli; sys.exit(cli())。安装后用户直接敲 textkit count ./docs 即可。
推荐用 uv tool install 或 pipx 安装——它们把工具装进独立虚拟环境并把入口软链到 ~/.local/bin,避免污染项目依赖:
uv tool install textkit
pipx install textkit
注意:本机没有 setuptools/hatchling/wheel 任何构建后端,且不允许联网安装,因此本节未实际构建 wheel——上面的 pyproject.toml 与安装命令是配置与流程说明,未实测。下面 zipapp 的部分全部真跑过。
14.2.3 zipapp:标准库的单文件分发
zipapp 的原理很朴素:Python 能直接执行一个 zip 文件(把 zip 路径加进 sys.path,再运行其中的 __main__.py)。所以在 zip 头部写一行 shebang、把 __main__.py 放进去,就成了一个可直接执行的文件。它不需要任何构建后端,纯标准库。
正确的目录布局是:入口 __main__.py 在归档根,包作为它的子目录:
app/
├── __main__.py # from textkit.cli import cli; cli()
└── textkit/
├── __init__.py
├── cli.py
└── core.py
一条命令把它打成 .pyz:
python -m zipapp app -o textkit.pyz -p "/usr/bin/env python3"
chmod +x textkit.pyz
-p 写入 shebang,chmod +x 让它可执行。真跑(实测,产物 31584 字节):
$ ./textkit.pyz count sample
文件 行 词 字符
-------------------------------------------------
a.txt 3 11 52
b.txt 2 9 50
c.txt 1 4 25
-------------------------------------------------
合计(3 个) 6 24 127
归档头的字节确实是 shebang:
00000000: 2321 2f75 7372 2f62 696e 2f65 6e76 2070 #!/usr/bin/env p
00000010: 7974 686f 6e33 0a50 ython3.P
用 zipapp --info 能读回解释器路径:
$ python -m zipapp textkit.pyz --info
Interpreter: /usr/bin/env python3
两种调用方式都要在文档里写清:./textkit.pyz(靠 shebang,需 POSIX 且已 chmod +x)与 python textkit.pyz(显式指定解释器)。Windows 不认 shebang,只能走后者,所以别只写 ./ 那一种。归档本身是个普通 zip,也能被 unzip -l 查看,便于排查「到底打进去了什么」。
14.2.4 经典陷阱:相对导入与「包根」
新手最容易犯的错,是直接把包目录本身当源码目录打包。如果 textkit/__main__.py 里写的是相对导入 from .cli import cli,打成 .pyz 后运行会直接崩:
$ python naive.pyz count sample
File ".../naive.pyz/__main__.py", line 3, in <module>
from .cli import cli
ImportError: attempted relative import with no known parent package
原因:当包目录成为归档根时,__main__.py 是顶层模块,没有父包,相对导入 from .cli 自然失败。对比两种布局就明白了:
naive 归档根: ['__init__.py', '__main__.py', '__pycache__/', ...] # 包成了根,无父包
正确归档根: ['__main__.py', 'textkit/', 'textkit/__init__.py', ...] # 包在根之下
正确做法两条:归档根的 __main__.py 用绝对导入(from textkit.cli import cli),并且把包放进一个子目录。同一份源码,仅改布局就从「崩溃」变成「可执行」。
14.2.5 用 Python API 构建:create_archive 与 filter
python -m zipapp 之外,zipapp.create_archive 提供等价的 API,方便写进构建脚本:
import zipapp
zipapp.create_archive(
"app",
target="textkit.pyz",
interpreter="/usr/bin/env python3",
filter=lambda p: "__pycache__" not in str(p) and p.suffix != ".pyc",
)
filter 参数很实用:默认会把源码目录里的 __pycache__/*.pyc 一并塞进归档(既占体积、又可能因 Python 版本不匹配引发问题),用 filter 排除掉最干净。
另外注意:-m 入口与已有的 __main__.py 互斥,同时给会报错:
ZipAppError: Cannot specify entry point if the source has __main__.py
即:要么让 zipapp 用 -m "textkit.cli:cli" 自动生成 __main__.py(此时源码目录里不能已有它),要么自己写好 __main__.py 让 zipapp 直接用。
14.2.6 依赖怎么办:vendor 进归档
zipapp 只打包你的代码,不打包第三方依赖。textkit 依赖 click,而归档里没有它——运行时用的是解释器环境里的 click。验证一下归档内容:
$ python -c "import zipfile; print(zipfile.ZipFile('textkit.pyz').namelist())"
['__main__.py', 'textkit/', 'textkit/__init__.py', 'textkit/cli.py', ...] # 没有 click
要让 .pyz 真正自包含,就把依赖一起放进源码目录再打包。实测把 click 复制进去后,归档从 31584 字节(约 31 KB)涨到 454085 字节(约 443 KB);用 python3 -S(禁用 site-packages)运行,自包含版照样能跑,而不带依赖的版本立刻报错:
$ python3 -S textkit_full.pyz words sample -n 3
the 3
python 2
hello 2
$ python3 -S textkit.pyz words sample -n 3
ModuleNotFoundError: No module named 'click'
结论:zipapp 的「单文件」是「你的代码单文件」,第三方依赖要么让用户环境自带,要么 vendor 进归档。 纯标准库工具(比如把 14.1 的统计逻辑改用 argparse 重写)是 zipapp 的最佳场景,体积小、零依赖、拷了就能跑。
14.2.7 压缩
zipapp 默认不压缩(store 模式),加 -c/--compress 用 deflate 压:
python -m zipapp app -o textkit.pyz -p "/usr/bin/env python3" -c
实测同一份带依赖的源码:store 模式 1045716 字节(约 1.0 MB),deflate 后 365738 字节(约 357 KB)——压缩率约 65%。体积敏感时值得加,代价是启动时要解压、冷启动略慢。
14.2.8 PyInstaller:打包成原生可执行(本机未实测)
当用户完全没有 Python 时,zipapp 无能为力——它需要一个 Python 解释器来跑。这时要用 PyInstaller 把「解释器 + 依赖 + 你的代码」一起打成一个原生可执行文件。
本机未安装 PyInstaller(pip show pyinstaller 报 not found),以下命令与配置未实测,仅为流程说明:
pyinstaller --onefile --name textkit \
--hidden-import click \
src/textkit/__main__.py
| 参数 | 作用 |
|---|---|
--onefile | 打成单个可执行文件 |
--windowed | 不弹控制台窗口(GUI 用) |
--hidden-import | 声明动态导入、静态分析漏掉的模块 |
--add-data | 附带数据文件(平台分隔符不同) |
--exclude-module | 排除无用大模块(如 tkinter、matplotlib) |
PyInstaller 的代价要提前认清:
- 体积大:每个平台各打一份,
--onefile冷启动要解压到临时目录,可能 3~5 秒。 - 不能交叉编译:Linux 上打不出 Windows 的 exe,必须在 CI 的对应平台 runner 上分别构建。
- 动态导入要手动声明:
--hidden-import漏一个,就是「开发正常、打包闪退」。 - 签名:Windows 未签名会触发 SmartScreen,macOS 必须签名 + 公证,否则用户打不开。
14.2.9 怎么选
- 纯标准库 / 内部工具 / 对方有 Python →
zipapp,零构建后端、几十 KB、秒级构建。 - 有第三方依赖但对方有 Python → 要么 vendor 进
.pyz,要么走console_scripts+uv tool install。 - 对方没有 Python / 要发给非技术用户 → PyInstaller,接受体积、构建矩阵与签名成本。
选型的本质还是那句话:你愿意让用户侧承担多少环境,就决定构建侧要付出多少。
延伸阅读
- Python 命令行工具:argparse、Click 与 Typer
——
console_scripts、uv tool install与容器化分发 - wheel、入口点与发布到 PyPI —— 从源码到可安装分发包的完整链路
- 依赖解析与锁文件 —— vendor 依赖前先想清楚版本锁定
小结
- 分发三形态按「用户侧依赖多少」划分:源码+入口点、zipapp 单文件、原生可执行。
console_scripts([project.scripts])是最标准的入口点;推荐uv tool install/pipx隔离安装(本机无构建后端,未实测构建)。zipapp是标准库方案:Python 能直接执行 zip,头部写 shebang、根放__main__.py即可。- 铁律:归档根的
__main__.py必须绝对导入,且包要作为子目录——否则attempted relative import直接崩。 zipapp.create_archive(..., filter=...)排除__pycache__;-m与已有__main__.py互斥。- zipapp 不含第三方依赖:要么用户环境自带,要么 vendor 进归档(
click使归档约 31 KB → 443 KB)。 - PyInstaller 能产出无 Python 依赖的原生可执行,但体积、跨平台构建矩阵、签名都是真实成本(本机未实测)。
工具做好了、也打得出了,但它始终是「命令行」。有些场景——给非技术同事、需要可视化选目录、要点按钮——命令行并不友好。本节的最后一节,我们看桌面 GUI 怎么快速实现,把同一套 core 逻辑接到窗口上。
阅读导航:上一节:Click / Typer 构建 CLI · 下一节:桌面 GUI 快速实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。