2771 字
约 9 分钟
21
S10-A07 · Python 打包与分发:从脚本到可安装包

python main.py 在自己机器上很顺,换台机器报 ModuleNotFoundError,再换台报 ImportError: libssl.so.1.1,装到服务器上又卡在编译 C 扩展。三个报错不是同一个 bug,是三种分发问题。

分发要回答的只有一个问题:你交付的是"代码"、"可执行的东西",还是"一个确定的环境"。答案不同,产物、依赖声明、发布流程全不同。形态选错,后面每个报错都在为这个选择付利息。


1. 先分辨:环境问题还是分发问题

症状 归属 判据
ModuleNotFoundError: No module named 'xxx' 环境 装错了解释器或没装依赖
本地能 import,别人 clone 后不能 分发 缺 pyproject 声明或包未包含
ImportError: libxxx.so 分发(二进制) wheel 与目标平台 ABI 不匹配
版本能装但不能跑 分发(入口) 缺 console_scripts 或包数据
同一依赖在两台机器版本不同 环境 没有锁定
部署时现场编译失败 分发(形态) 只交付了 sdist,目标机没有编译链

用法是:先用报错定位归属,再选形态。环境问题当分发问题处理,得到一堆无用配置;分发问题当环境问题处理,会在目标机上反复 pip install。

2. 四种分发形态与判据

形态 产物 环境一致性 体积 适用
源码包 sdist .tar.gz 差,依赖目标机 小 库、需适配多平台
wheel .whl 中,依赖已编译产物 中 库与 CLI 默认
单文件 单二进制 好,自带解释器 大 目标机没有 Python
容器镜像 image 最好 最大 服务端部署

判据压缩成一句:给开发者 → wheel;给运维 → 镜像;给非技术用户 → 单文件;要二次开发 → sdist 与 wheel 都给。

3. pyproject.toml 与旧的 setup.py

三套元数据并存,关系是"新旧覆盖"而不是"三选一":pyproject.toml 是标准入口,构建后端由 [build-system] 指定;setup.py 是遗留的命令式脚本,只有需要动态计算元数据时才用;setup.cfg 是 setuptools 的声明式配置,正被前者取代。

[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "clarity-tools"
version = "0.4.1"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]

[project.optional-dependencies]
dev = ["pytest>=8", "ruff>=0.5"]

[project.scripts]
clarity-tools = "clarity_tools.cli:main"

判据:新项目只写 pyproject.toml;老项目保留 setup.py 只为兼容旧工具链,但不要在两处重复声明版本号——不一致时哪个生效取决于构建后端。

4. 依赖声明三档与"锁文件不属于库"

档位 写在哪 语义 谁该有
直接依赖 dependencies 运行时必需,给区间 所有包
可选依赖组 optional-dependencies 按用途分组安装 库(dev、docs、cli)
区间约束 >=x,<y 表达兼容范围 库
精确锁定 锁文件 复现某次构建 应用,不是库

原则很硬:锁文件属于应用,不属于库。库如果把 httpx==0.27.0 写进 dependencies,等于强迫所有使用者跟随你的版本,冲突迟早发生在别人的依赖树里。

5. 私有源与索引优先级

pip 的索引规则有一个容易忽略的组合风险:--index-url 替换默认索引,--extra-index-url 追加,而追加之后 pip 会在所有索引里挑版本号最高的那个。

写法 语义 风险
--index-url <private> 只用私有源 公开包缺失时会失败
--extra-index-url <private> 公开源 + 私有源,取最高版本 依赖混淆:公开源上的同名高版本包可能被选中
--no-index --find-links 只从本地目录装 离线可用,需自备全部 wheel
# ~/.config/pip/pip.conf —— 只用私有源,公开包由其同步
[global]
index-url = https://pypi.example.internal/simple
trusted-host = pypi.example.internal

判据:私有包名可能与公开包重名时,不要用 --extra-index-url。要么走一个同步公开包的私有索引,要么用 --no-index --find-links 完全受控地离线安装。

6. wheel 的平台标签:为什么只能本地装

wheel 文件名里的标签序列就是"这个包能在哪装"的声明:{python tag}-{abi tag}-{platform tag}。

python -m build --wheel                         # 只构建 wheel
pip debug --verbose | grep -A20 'Compatible tags'   # 当前解释器接受的标签
auditwheel show dist/*.whl                      # manylinux 合规与外部依赖
pip download --only-binary=:all: -d wheelhouse -r requirements.txt
标签 含义 装不上时
py3-none-any 纯 Python,任意平台 基本都能装
cp312-cp312-manylinux_2_17_x86_64 CPython 3.12 + manylinux macOS 或 ARM 上装不了
cp312-cp312-macosx_11_0_arm64 macOS ARM Linux 上装不了
py3-none-manylinux_2_28_aarch64 带 C 扩展 glibc 低时回退 sdist 编译

结论:"只能在本地装"通常是构建出了平台专属标签,目标机没有对应 wheel,于是回退 sdist 并现场编译。要么在 CI 里为每个目标平台各构建一次,要么用 manylinux 镜像构建拿到可移植标签。

7. 入口点、命名空间与包名冲突

在 [project.scripts] 里写 clarity-tools = "clarity_tools.cli:main",装完就有同名命令。约定:入口函数返回 int,异常在入口处翻译成退出码,别把 traceback 当输出。

冲突类型 表现 处理
发行名与导入名不一致 装 clarity-tools 后导入 clarity_tools 命名时固定两者
顶层包名太通用 utils 与他人包互相覆盖 加前缀或用命名空间包
命名空间包拆分 两发行包共享一父包 都用空 __init__.py 的 PEP 420 布局
命令名冲突 两包都装 cli [project.scripts] 加前缀

8. 可编辑安装与单文件打包

可编辑安装(pip install -e .)把源码目录挂进 sys.path,改代码立即生效。两个陷阱:不复制包数据与非 Python 文件(package_data 配错要等打包才暴露);多个可编辑安装会互相遮蔽同名顶层包。

单文件打包把解释器与依赖塞进一个产物:

pyinstaller --onefile --name clarity-tools \
            --hidden-import clarity_tools.plugins.loader \
            --collect-data clarity_tools \
            clarity_tools/cli.py
代价 原因 应对
体积大(几十 MB 起) 打进了整个解释器与依赖 --exclude-module 裁剪
启动慢 每次解压到临时目录 --onedir 或接受首次延迟
动态导入/插件找不到 静态分析看不到运行期 import --hidden-import 显式登记
数据文件缺失 未声明为非代码资源 --add-data 或 --collect-data
杀软误报 自解压行为特征 换签名与分发渠道,而非改打包参数

判据:只有目标机器上不能保证有 Python 时,单文件才划算。能装 Python 的环境里 wheel + 入口点更轻更好排错。

9. 容器镜像作为分发形态

把解析结果冻进镜像,等于把"环境一致性"从文档承诺变成产物属性。

FROM python:3.12-slim AS build
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen --no-dev

FROM python:3.12-slim
COPY --from=build /app/.venv /app/.venv
COPY src/ /app/src/
ENV PATH="/app/.venv/bin:$PATH"
ENTRYPOINT ["clarity-tools"]
收益 代价
依赖版本、解释器、系统库一次固定 体积(基础镜像几百 MB 起)
产物可直接回滚(换 tag) 构建时长,缓存没命中就重装
与编排系统天然兼容 调试要进容器
目标机不必装编译链 安全更新靠重建,不是打补丁

取舍:镜像解决"环境一致",不解决"包质量"。依赖没声明清楚的项目做成镜像只是把问题冻住,重新构建时照样炸。

10. 发布流程的最小集

python -m build                          # 1 构建 sdist + wheel
python -m twine check dist/*             # 2 检查元数据与 README
python -m twine upload -r testpypi dist/*  # 3 先发测试索引
python -m twine upload dist/*            # 4 正式上传
环节 能否回滚 纪律
构建 能,重跑 版本号单调递增,不复用
检查 能 元数据错误本地发现
上传测试索引 能 装一次验证入口点
上传正式索引 不能删改同版本 只能 yank 后发新版
部署 能,换旧 tag 镜像 tag 与版本号对应

最后一条是流程里唯一不可逆的动作:正式索引上的版本号一经发布不能真正撤回。所以"先发测试索引并装一次"不是仪式,是唯一能拦住不可逆错误的步骤。

11. 排错表

现象 最可能原因 处理
ModuleNotFoundError 但依赖已装 导入名≠发行名,或装在别的解释器 pip show -f <name> 核对
pip install . 现场编译失败 交付了 sdist,目标机缺编译链 构建目标平台 wheel 或 manylinux
装上了但命令不存在 缺入口点或未重装 查 [project.scripts],强制重装
装到同名公开包 用了 --extra-index-url 单一索引或 --no-index
可编辑安装后改代码不生效 装成非可编辑,或被另一份遮蔽 pip show 看 location,清理重复
插件加载失败 动态 import 未被静态分析发现 显式 --hidden-import

12. 边界:什么时候不必做成包

场景 结论 理由
内部脚本、单机跑 不做包 一个目录 + uv run
依赖极少(标准库为主) 不做 wheel 分发成本高于收益
只自己用 不做入口点 python -m 够用
单机环境固定 不做镜像 直接 venv 更轻
原型阶段 不做发布流程 版本号与索引都是负担

必须做的信号:三人以上用、两台以上机器以相同方式运行、或已出现过"在我机器上是好的"。


13. 自测

  1. 同事 clone 后 ModuleNotFoundError,你判断是环境问题还是分发问题,依据是什么?
  2. 什么情况下应该交付镜像而不是 wheel?
  3. 为什么锁文件不该出现在一个库的发布产物里?
  4. --extra-index-url 与 --index-url 的差别,会带来什么具体风险?
  5. wheel 装不上的时候,pip 通常会退到什么路径,为什么那会失败?
  6. 单文件打包在什么前提下才划算?
S10-A07 · Python 打包与分发:从脚本到可安装包
https://www.clxhxhhr.top/posts/6206/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。