刚接手一个多模块仓库时,我在requirements.txt里写了一行-e ./sdk/auth,然后执行pip install -r requirements.txt,结果直接弹出subprocess-exited-with-error,中间那句preparing metadata (pyproject.toml) did not run successfully让我盯着终端看了三分钟。第一反应是“这目录里明明有 setup.py 啊,为什么跟我谈 pyproject.toml?”后来把整个构建链路翻完才发现,这压根不是某个小配置写错,而是对现代pip的可编辑安装机制理解不到位。
这篇文章就是来彻底解决这个问题的。我会从pip在requirements.txt场景下如何解析可编辑安装路径、为什么要找pyproject.toml、构建后端如何参与 metadata 生成,一直讲到可落地的三种修复方案、常见报错排查表。不管你是维护 monorepo 还是只是从 Git 子目录里拉了一个本地包,这篇文章应该都能帮你少熬夜。
1. 先弄明白:为什么 pip 非要找 pyproject.toml
1.1 现代 pip 的构建隔离机制
从 pip 21.3 开始,默认构建行为发生了明显变化:安装本地目录包时,pip 会按照 PEP 517/518 的流程,先进入一个隔离的构建环境,在这个干净环境里安装pyproject.toml中[build-system]声明的构建依赖,再调用build-backend去生成 metadata 或 wheel。
打个比方,以前pip install -e ./sdk/auth的运行逻辑很简单——直接到那个目录里执行python setup.py develop完事。现在不行了,pip 会像一个检查员一样先问:“这个目录的构建系统声明在哪里?”它期望看到一个pyproject.toml,里面写清楚requires和build-backend。如果只有setup.py而没有pyproject.toml,pip 虽然会走 setuptools 的传统兼容路径,但一旦你在requirements.txt里跨目录引用,或者同时存在多个 Python 版本、多个构建后端,就容易在这种 metadata 准备阶段出问题。
这里要区分清楚,prepare metadata和build wheel是两个环节。可编辑安装到 2023 年后的处理,通常是先利用构建后端生成 editable wheel,然后再安装到环境中。生成 editable wheel 之前必须能解析出包的基本信息,也就是 name、version、dependencies 等 metadata。而pyproject.toml这种声明式文件就是现代 pip 用来完成解析的第一入口。
1.2 子目录场景为什么特别容易踩坑
假设你的仓库结构是这样的:
my_repo/ ├── requirements.txt ├── pyproject.toml └── sdk/ ├── auth/ │ ├── setup.py │ └── auth_lib/ │ └── __init__.py └── payment/ ├── pyproject.toml └── payment_lib/ └── __init__.pyrequirements.txt里通常有两种写法:-e ./sdk/auth或者-e sdk/auth。问题的核心在于,./sdk/auth这个路径是相对于当前执行pip install的工作目录来解析的。而pyproject.toml却并不总在sdk/auth目录内。很多老项目习惯把pyproject.toml放在仓库根目录,然后用setup.cfg或setup.py完成子包配置。
当 pip 把工作目录切到sdk/auth去找构建文件时,它第一个找的就是pyproject.toml。找不到?没关系,传统 setuptools 兼容路径可以兜底。但问题往往出在另一个细节:你环境里安装的 setuptools 版本太老或太新,pip 生成 legacy editable metadata 的方式和当前代码结构对不上,于是报preparing metadata (pyproject.toml) did not run successfully。
还有一种子目录反直觉场景:仓库根目录有pyproject.toml,但sdk/auth子模块没有。运行时你以为 pip 会“聪明地读取父级配置文件”,实际不会。pip 只会把目标目录当作独立项目来处理,它不会向上层目录去搜索pyproject.toml,所以缺了就是缺了,报错非常直接。
1.3 版本差异让同一个报错看起来千差万别
我这边实测下来,同样的目录结构,在不同 pip 版本下报错内容不完全一样。pip 20.x 可能给出的是Error: can't find Python executable或者Invalid requirement;pip 21.3 到 22.x 经常是preparing metadata (pyproject.toml) did not run successfully;pip 23.x 以后则会更清晰地加上error: subprocess-exited-with-error的顶层字样,而且会打出Getting requirements to build wheel ... done这类中间状态。
这个差异不是 pip 变得不稳定,而是它默认启动了 PEP 517,并且在逐步淘汰传统的setup.py develop路径。如果不想被各种形式的报错牵着走,最稳妥的思路还是把子项目补充成标准的、自包含的构建结构。
2. 定位问题:不要只看最后一行报错
2.1 常见报错文案和对应原因速查
我在处理这个问题的过程中,整理了四类最高频的报错,它们的根因不同,修法也完全不同:
| 报错片段 | 真实原因 | 修复方向 |
|---|---|---|
preparing metadata (pyproject.toml) did not run successfully | 构建隔离环境中运行构建后端时失败,最常见是 setuptools 版本不够或缺少 pyproject.toml | 补最小 pyproject.toml,或升级环境中的 setuptools 后禁用隔离 |
error: subprocess-exited-with-error | 这是顶层的包装错误,需要往上翻找到具体子进程命令 | 看前 30 行日志,确认是哪一步失败 |
error: metadata generation failed | 通常意味着 setuptools 无法从目录结构中读取版本号或包名,比如缺少__init__.py或version字段 | 检查包目录结构,确认存在有效包名和版本 |
Legacy editable install of x==... (setup.py develop) is deprecated | 环境中的 setuptools 提示老式可编辑安装将被弃用 | 升级 setuptools 到 64 以上,并确保 pyproject.toml 存在 |
看到表格里第二行要特别提醒:subprocess-exited-with-error只是结果外壳,真正有价值的细节藏在它上面的十几行甚至几十行里。如果不往上翻就急着改配置,大概率会白忙活。
2.2 三步定位法:从目录到构建日志
我的习惯是严格按下面三步来排查,基本能一分钟内锁定问题所在。
第一步,打印完整日志并打开详细模式:
python -m pip install -r requirements.txt -vvv注意这里用的是python -m pip而不是裸pip。在存在多个 Python 环境的机器上,python -m pip能确保安装进入当前解释器对应的环境,避免 pip 和 python 版本错配。加了-vvv后可以看到 pip 到底执行了什么命令、加载了哪个build-backend、装了什么构建依赖。
第二步,检查目标子目录的文件结构。尤其是要看有没有pyproject.toml、setup.py、setup.cfg这三个关键文件:
ls -la sdk/auth/建议同时还跑一下 find,确认没有把pyproject.toml放在奇怪的位置:
find sdk/auth -maxdepth 2 -name "pyproject.toml" -o -maxdepth 2 -name "setup.py"第三步,验证这个子目录能否独立构建。进入目标目录,直接单独执行可编辑安装。这一步能帮助切分“子目录本身是否有问题”和“requirements.txt 引入方式是否有问题”:
cd sdk/auth python -m pip install -e .如果这时候报错,说明问题在子项目自身;如果这一步成功但上一级pip install -r requirements.txt失败,说明问题出在路径解析或构建环境差异上。
3. 三种修复方案:从补文件到改策略
3.1 方案一:补一个最小可用的 pyproject.toml
这是我最推荐的修复方式,因为补完以后子目录就是一个标准化的独立项目,不仅能解决当前requirements.txt的问题,后续直接拿到其他环境安装也不容易再踩坑。
以sdk/auth为例,假如它使用的是传统 setup.py 加 setup.cfg,那么补一个最小的 pyproject.toml 就行:
[build-system] requires = ["setuptools>=64", "wheel"] build-backend = "setuptools.build_meta"很多教程会建议requires = ["setuptools>=61"],但我个人更愿意写>=64。原因很简单:editable wheel 的稳定支持是在 setuptools 64 之后才真正成熟的。如果你的包需要在较新的 pip 下频繁进行可编辑安装,直接用>=64能少折腾很多事。
把这个文件放到sdk/auth/pyproject.toml后,重新回到仓库根目录执行:
python -m pip install -r requirements.txt这时候 pip 会先创建隔离环境,在隔离环境里安装setuptools>=64和wheel,然后调用setuptools.build_meta读取包配置。这个过程看起来很简单,但背后解决了一个关键问题:pip 不需要再依赖宿主机环境里的 setuptools 版本了,即使你环境下 setuptools 是 58,它也能在隔离环境中给你拉一份 66 来用。
3.2 方案二:调整 requirements.txt 写法和安装策略
如果你出于某些原因不想动子项目的文件结构,改动安装方式也是一个合理路径。
先说路径写法。-e ./sdk/auth和-e sdk/auth表面差别不大,但一旦requirements.txt存放位置与执行位置不一致,这两种写法都会产生迷惑行为。我的建议是统一用相对仓库根目录的路径,并且尽量写成带./前缀的明确形式。如果你需要从仓库根目录以外的位置执行安装,建议直接使用绝对路径前缀,比如:
-e /workspace/my_repo/sdk/auth或者利用 shell 变量拼接:
# requirements-local.txt 中 -e "$PWD/sdk/auth"但这里必须强调,requirements.txt文件里默认不做 shell 变量展开,所以上面那种写法实际上不能直接写在requirements.txt里。你只能在命令行做拼接:
python -m pip install -e "$PWD/sdk/auth"如果只是想临时解决报错,还有一种更加省事的方法:去掉-e,改成普通安装。
file:///workspace/my_repo/sdk/auth或者直接:
sdk/auth普通安装不需要生成 editable wheel,pip 可以直接构建普通 wheel 并安装。缺点是后续改代码不能实时反映到你的开发环境,需要重新执行安装。对于 CI/CD 场景反而更合适,因为普通安装比 editable 安装更接近最终部署形态,而且少了一层路径重定向,出 bug 的概率更低。
3.3 方案三:处理构建隔离问题的高级选项
如果子项目目录本身构建正常,只是每次pip install -r requirements.txt都会卡在“构建隔离环境下载依赖”这一关——比如网络不好、内网源没配、公司防火墙拦截了 pypi.org 请求——那我建议临时跳过构建隔离:
python -m pip install -r requirements.txt --no-build-isolation使用这个参数之前,必须先手动确保当前环境中已经装好构建依赖:
python -m pip install "setuptools>=64" wheel这样 pip 会直接使用你当前环境中的 setuptools 来执行构建,不再去下载pyproject.toml里声明的构建依赖。这个方法很适合离线环境,但它只是绕过问题。如果子目录的构建配置本身有问题,比如缺版本号字段,那么--no-build-isolation并不会帮你治好它,报错内容还会继续。
另一个可以使用的高级选项是强制使用 PEP 517 流程,适合怀疑 pip 走传统路径出错的情况:
python -m pip install --use-pep517 -r requirements.txt当你有多个本地子项目依赖时,还可以把本地索引加上,方便 pip 统一处理 local 包:
python -m pip install -r requirements.txt --find-links ./dist不过--find-links的前提是你预先构建好了自定义包,不适合直接用于本地源码目录,这里就不展开讲了。
3.4 升级环境中的 setuptools,经常被忽略的一步
很多情况下,子目录里确实有完整的pyproject.toml,也声明了 setuptools 版本要求,但宿主机环境中的 setuptools 实在拉胯,在生成 metadata 时直接崩溃。典型的例子是 setuptools 58 遇到新版 pip 处理包内数据文件时会出现的各种异常。
所以无论用哪种方案,我之前都会顺手把虚拟环境里的核心构建工具升级一遍:
python -m pip install --upgrade pip setuptools wheel这一步特别适合刚创建全新虚拟环境、什么基础包都没装的情况。我见过太多“安装包报错”最后都是因为 setuptools 没升上去。
4. 常见报错与避坑实录
4.1 一个被 PEP 668 拦住的场景
如果你的requirements.txt里还包含其他依赖,比如想装PySide6或者 OpenPyXL,但执行命令的时候冒出来的是:
error: externally-managed-environment这说明当前 Python 是系统级受管环境,操作系统或发行版限制直接用 pip 往全局环境里装包。这种情况下,直接加--break-system-packages是一时爽后患无穷,很容易搞乱系统依赖,尤其 Ubuntu 和 Debian 桌面环境非常容易遇到。
我的建议是立刻为项目建独立虚拟环境:
python -m venv .venv source .venv/bin/activate python -m pip install -r requirements.txt如果项目是在 Windows 上,激活命令换一下:
.venv\Scripts\activate同时检查一下环境中是不是已经默认到用户目录安装依赖。Windows 上安装requests时常见下面这种提示:
Defaulting to user installation because normal site-packages is not writeable意思是当前 Python 安装目录没有写权限,pip 自动把包装到了用户 site-packages。这些包虽然能用,但会让环境管理变得混乱,还是尽早用虚拟环境隔离比较好。
4.2 requirements.txt 本身的隐藏坑
在处理这个问题时,我发现不少报错其实不是“构建缺 pyproject.toml”造成的,而是被 requirements.txt 的细节坑了。比如,文件里存在中文注释或非 UTF-8 编码时,pip 在某些系统下会直接跳过或报编码错误。建议文件统一用 UTF-8 保存,并尽量不用中文写注释。
再比如多行续接符。requirements.txt 支持用\续行,但如果你在-e ./sdk/auth后面不小心加了一个空格再加\,pip 解析时会认为你多了一个空参数,报错:
ERROR: You must give at least one requirement to install这种错和 pyproject 完全无关,但报错时机和位置很容易让你跑偏。遇到这个提示,优先检查requirements.txt里的空行、尾缀空格和重复的空参数,不要一上来就给子目录加构建文件。
4.3 一个可编辑安装与生产构建的判断依据
最后讲一个原则问题:什么时候该用-e,什么时候不该用。
如果是日常开发,想要改了源码立刻能在项目里看到效果,-e是必须的。它相当于给当前环境做了一个指向源码目录的软链,省去每次修改后重新 install 的重复劳动。但这种便利只对本地开发友好。
如果是部署、测试或者 CI,可编辑安装反而会成为麻烦。因为在容器或云函数环境里,源码目录不一定一直存在,而且构建过程还要额外处理 editable wheel,徒增网络调用和构建时间。这时候直接使用普通安装,或干脆把包打成 wheel 后从本地索引安装,更能减少意外。
我自己的实践是,开发环境里保留-e引用,用于 CI 和部署的 requirements 文件则完全不使用可编辑安装。两套文件分开维护,只在有新增依赖时才同步更新。这个方法不怎么高雅,但确实帮我挡掉了大量环境差异导致的构建问题。
5. 排查顺序与验证清单
为了让整个修复过程有迹可循,我把最终通关顺序整理成一份清单,方便你照着做:
- 确认进入正确的虚拟环境:
which python和python -V。 - 升级基础构建工具:
python -m pip install --upgrade pip setuptools wheel。 - 用
-vvv模式重新安装,记录完整报错位置。 - 进入报错的目标子目录,检查
pyproject.toml与setup.py、setup.cfg是否存在。 - 在目标子目录内单独执行
python -m pip install -e .,确认项目自身能否构建。 - 根据实际情况选择:补最小
pyproject.toml,或改用普通安装,或加--no-build-isolation。 - 返回仓库根目录,删除 pip 缓存路径后重新执行安装(避免旧构建缓存干扰)。
其中第 7 步很多人会忽略。有时你改了配置重新跑还是旧报错,就是 pip 的 wheel 缓存里躺着失败记录。清理缓存可以这样:
python -m pip cache purge之后再安装,往往就像换了台机器一样顺滑。
我在实际处理中体会到,这类问题和网上流传的“直接删掉 setup.py 改用 pyproject”不完全是同一回事。每个仓库的历史包袱不同,有人是折中保留 setup.py,有人是目录层级套得太深,还有人只是 pip 版本与 setuptools 版本不匹配。最稳的做法永远是把日志完整贴出来看,而不是靠猜。
如果让我给一个模板建议,那我真心建议你在任何本地 Python 子项目目录都放一个最小 pyproject.toml,内容不到十行,类似这样:
[build-system] requires = ["setuptools>=64"] build-backend = "setuptools.build_meta"复制过去时顺手改掉名字和版本来源,剩下的交给 setuptools。这个习惯养成之后,再遇到pip install -r requirements.txt里引用子目录可编辑包的情况,基本可以一次通过,连preparing metadata的字都见不到。