我希望把 Python 程序打包给别人用的时候像普通软件一样简单
接上回
入职新公司之后遇到 Python 打包发布的问题, 由于基础建设以及技术选型等历史遗留问题, 只能使用 Python 写的工具, 所以基本告别 pipenv
/ venv
/ poetry
等常见的发布方式, 遇到需要第三方依赖的功能, 打包依赖变得非常费劲.
考虑过像 go 一样打包一些二进制文件, 但是性能和兼容性等方面特别不友好, 所以还是打算使用 zipapp
那种方式来搞. pyinstaller / venv / pipenv / poetry / setuptools / shiv / PyOxidizer
这些的使用体验, 其实本身还是不错的.
新的选择
目前这类比较成熟的几个打包用的库里面, shiv
算是生态还算健康的选择, 类似的 pex
/ superzippy
使用过程中有挺多缓存或者其他不方便的地方, 所以用了大概三个多月 shiv
(https://github.com/linkedin/shiv), 期间挺多小问题能解决的都解决了, 不能解决的提 issue 作者也比较快地支持了.
然而最后还是选择自己开发个类似项目 zipapps
, 因为下面几个原因 shiv
支持的不是很方便:
- 不能在打包时候设置默认
SHIV_ROOT
默认推到 HOME 目录在某些场景缺少 HOME 会报错, 每次手动定义这个环境变量又感觉很冗余- 提过 issue 以后目前支持上了
- 每次重新打包以后要重新创建缓存, 时间久了导致系统磁盘浪费几 GB 空间, 其实这是最主要的那个原因
- 有别人提了
build_id
的建议, 貌似没通过
- 有别人提了
- 每次需要解压全部内容做缓存
- 实际上
zipapp
借助zipimport
自带能力, 有的库是不需要解压的, 这样可以保证发布时的standalone
特性
- 实际上
- 跨平台和跨 Python 版本能力比较薄弱
- 比如 Python3.7 打包的依赖里有 C 编译的库, 这就没法给 Python 3.8 使用, 再比如 Windows 上打包的代码, 到了 Linux 上也有可能有兼容问题
- 有时候需要合并多个
pyz
文件里的依赖一起使用, 就像是添加了多个sys.path
- 比如: 有一个包含 bottle 的
bottle.pyz
, 还有包含 requests 的requests.pyz
, 二者组合使用
- 比如: 有一个包含 bottle 的
使用 Zipapps
简单的使用
安装
python3 -m pip install -U zipapps
场景 1
需要打包发布 Linux 打包机上的工具
- 假设有一个爬虫脚本, 依赖了第三方库
requests
python -m zipapps -c -a entrypoint.py -m entrypoint:main -o crawler.py requests
- 现在当前目录得到一个后缀名是
py
的 zip 文件crawler.py
- 这里使用
py
做后缀名, 是因为有的系统可以直接双击运行, 否则以zip
结尾更准确, 以pyz
结尾更富含语义
- 这里使用
- 通过
python3 crawler.py
执行即可完成任务
特殊情况:
- 如果依赖的除了
requests
, 还有psutil
这类包含.so
/.pyd
的 C 编译的库, 则需要添加以下参数保证执行的时候将它们缓存到本地, 否则无法正确导入-u="*"
可以在运行时解压所有文件, 如果只希望解压最小数量, 可以使用-u="AUTO"
替代- 默认不指定
-up
参数的时候, 会在当前目录下解压:./zipapps_cache/crawler
- 如果不希望执行的时候输入
python3
, 可以通过以下参数指定默认解释器路径, 然后使用./crawler.py
直接运行-p /usr/bin/python3
会将参数的内容放入首行shebang
- 其实更常用的路径是虚拟环境的路径, 依赖就不一起打包了而是放在虚拟环境里
-p {PATH_TO_VENV}/bin/python
场景 2
将代码打包成跨平台 + 跨 Python 版本的工具
使用延迟安装模式 lazy install
- 同场景 1 的情况, 只不过参数里加入了一个
-d
python -m zipapps -c -d -a entrypoint.py -m entrypoint:main -o crawler.py requests
- 打包之后的文件很小, 因为只包含
entrypoint.py
代码和一些其他 meta 信息 - 执行的时候和之前一样
python3 crawler.py
- 然而这次执行会先通过
pip
安装requests
这个依赖- 只会安装到临时缓存目录里, 不会影响全局
- 只会在首次运行时候安装, 之后就算多次打包, 如果依赖的东西不变, 也不会重新安装
- WARNING: 由于启动了
-d
模式, 默认的缓存路径由原本的./zipapps_cache/crawler
变成了SELF/zipapps_cache/crawler
- 当然可以手动改成别的
-up=xxx
- 这里的这个
SELF
是个内置变量- SELF: 打包文件自身目录
- HOME: 当前用户的 HOME 目录
- TEMP:
tempfile.gettempdir()
临时目录
- 当然可以手动改成别的
场景 3
代码与依赖分离
可以参考 --zipapps
参数的用法, 不太常用不再赘述
python3 app.pyz --zipapps=venv1.pyz,venv2.pyz
解决之前的问题
问题 1
- 默认路径已经变成了
./zipapps_cache/{app_name}
- 当然也可以在打包时候通过
-up
自定义指定- 也可以在运行时通过环境变量覆盖掉原本指定的路径
- 支持
SELF/TEMP/HOME
内置变量, 也可以使用./
这种和当前工作目录有关的相对路径
问题 2
- 在不修改默认
build_id
的前提下, 每次打包判断是否解压缩是根据打包时间戳来决定的 - 解压的时候, 不管时间戳怎么变,
-o
指定的默认 app 名称将作为唯一路径, 不会新建目录- 默认
-o
是app.pyz
- 默认
问题 3
- 像
bottle
/requests
之类纯 Python 编写的依赖, 是不需要解压缩的 - 如果遇到需要解压缩
.so
等文件, 可以通过-u
参数来指定, 常用参数有-u AUTO
自动解压带.so
/.pyd
的依赖-u file1.py,dir1
解压指定的文件或文件夹, 一般只支持根目录的文件名-u="*"
解压所有文件, 这里注意不能使用-u *
, 因为 Linux 上*
是有特殊意义的, 不是普通字符串
问题 4
-d
延迟安装这个参数就是为跨平台和跨版本准备的- 延迟安装的目录会根据当前解释器与操作系统名称来创建, 互相隔离
- 目前 Python 版本隔离的精度是 2, 也就是 3.7 和 3.8 共用一个目录
- 如果需要调整精度, 可以使用
-pva 3
或者-pva 1
来调大调小精度
- 如果需要调整精度, 可以使用
问题 5
- 上面提到的场景 3 里面
--zipapps
参数即可, 后来发现这么用不是很优雅- 不过快速导入其他
pyz
里面的代码的时候, 或者共享依赖的时候, 还是有点用的
- 不过快速导入其他
小结
其实一开始打算参考一下 shiv
的源码直接 fork, 后来发现… 里面实在太复杂了, 只好硬啃官方文档有关 zipapp
和 zipimport
的内容自己整. 等有时间还是得看看 shiv
在底层有没有目前我没有想到的一些细节和优化, 毕竟我一个人能测试或者思维的局限性还是挺明显的.
生产环境已经投入使用, 稳定性方面还没出现问题, 期间断断续续 fix 了十几个 bug 并新增了挺多正好用到的功能.
简而言之, 开发 Zipapps
的初衷其实很简单: 我希望把 Python 程序打包给别人用的时候像普通软件一样简单.
感兴趣的话, 欢迎来提 Issue: https://github.com/ClericPy/zipapps , 不感兴趣的话, 估计也看不到这一行.