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. 自测
- 同事 clone 后
ModuleNotFoundError,你判断是环境问题还是分发问题,依据是什么? - 什么情况下应该交付镜像而不是 wheel?
- 为什么锁文件不该出现在一个库的发布产物里?
--extra-index-url与--index-url的差别,会带来什么具体风险?- wheel 装不上的时候,pip 通常会退到什么路径,为什么那会失败?
- 单文件打包在什么前提下才划算?