PEP 440 版本约束:~= 位数、== 零填充与 pip 误报复盘
TL;DR: 我把六条真实报错和三个静默失效的约束写法逐一复现了一遍,最大的坑不在报错里,而在报错之外:pip 那句「Additionally, some packages ... have no matching distributions available」经实测是误报——它列出的包在索引里明明有版本。
~=2.2放行 2.9 但挡住 3.0,~=2.2.0直接把 minor 锁死。差一位,语义完全不同。==1.1会接受1.1+downstream1,因为 PEP 440 规定当约束里写的是公共版本号时,本地标签被整体忽略。<1.7不会接受1.7.0a1,但<1.7rc1会接受1.7a1——想放行预发布必须写到具体 rc 号。Specifier("~=1")直接抛InvalidSpecifier,单段版本号禁止用~=。- packaging 官方文档站展示的是 26.x 的 API,而本机 25.0 上
is_unsatisfiable、to_range等六个方法全部不存在。
本文所有数字都是在 Python 3.13.14 + packaging 25.0 + pip 26.1.2 上跑出来的,脚本原文都给在正文里,你可以逐条复算。
先说清楚本文的验证环境
这一节很重要,因为本文的每条结论都跟版本绑定:
- Python 3.13.14
- 独立的 packaging 库版本 25.0
- pip 版本 26.1.2,它内部 vendored 的 packaging 是 26.2
注意最后一条:同一台机器上同时存在两个 packaging(一个 25.0 是你自己装的,一个 26.2 被 pip 打包进去了)。本文讲 Specifier 行为的结论来自独立的 25.0,pip 的报错则来自 pip 26.1.2 自己的解析器。这两个版本对预发布的默认处理并不完全一致,后面会专门讲。
另外,本文所有 pip 报错都是在本机用手工构造的本地 wheel 复现的(不联网、不装 setuptools),因为出网受限。命令全部可以直接复制运行。
一、~= 的位数就是兼容范围的开关
~= 是最容易写错的一个运算符,因为它允许的上界完全由你写了多少段数字决定。
PEP 440 原文的表述是:对于 V.N,兼容发布子句「approximately equivalent to the pair of comparison clauses」>= V.N, == V.*。
实测对照(本机 packaging 25.0):
| 写法 | 2.2 | 2.9 | 3.0 | 2.1.9 |
|---|---|---|---|---|
~=2.2 | 命中 | 命中 | 不命中 | 不命中 |
~=2.2.0 | 命中 | 不命中 | 不命中 | 不命中 |
差一个 .0,就把允许范围从整个 2.x 收窄到 2.2.x。同样的事在 patch 段更夸张:
| 写法 | 1.4.5 | 1.4.6 | 1.4.9 | 1.5.0 |
|---|---|---|---|---|
~=1.4.5 | 命中 | 命中 | 命中 | 不命中 |
~=1.4.5.0 | 命中 | 不命中 | 不命中 | 不命中 |
~=1.4.5.0 只放行 1.4.5.x 这一条线,1.4.6 都进不来。
还有两个容易忽略的细节。第一,带后缀时后缀只影响下界,上界仍按数字段算:
SpecifierSet("~=2.2.post3"):2.2 不命中(低于下界),2.9 命中,3.0 不命中
SpecifierSet("~=1.4.5a4"):1.4.5a4 命中,1.4.9 命中,2.0 不命中
PEP 440 对此有专门说明:如果预发布、后发布或开发发布以 V.N.suffix 形式出现,「the suffix is ignored when determining the required prefix match」——后缀在判断前缀时被忽略。
第二,单段版本号是硬错误:
>>> from packaging.specifiers import Specifier
>>> Specifier("~=1")
packaging.specifiers.InvalidSpecifier: Invalid specifier: '~=1'
PEP 440 用的是 MUST NOT 级别的措辞:「This operator MUST NOT be used with a single segment version number such as ~=1」。
可以直接跑这段对照:
from packaging.specifiers import SpecifierSet
from packaging.version import Version
for spec in ["~=2.2", "~=2.2.0", "~=1.4.5", "~=1.4.5.0"]:
ss = SpecifierSet(spec)
row = ", ".join(
f"{c}={'Y' if Version(c) in ss else 'N'}"
for c in ["1.4.5", "1.4.6", "1.4.9", "1.5.0"]
)
print(f"{spec:12} -> {row}")
二、== 的零填充,以及它为什么放过了本地版本
== 的比较不是纯字符串相等。PEP 440 规定它会做唯一一处替换:「The *only* substitution performed is the zero padding of the release segment」。
所以:
>>> Version("1.1.0") == Version("1.1")
True
但也就仅此一项。下面这些全部不等于 1.1:
Version("1.1.dev1") # False
Version("1.1a1") # False
Version("1.1.post1") # False
Version("1.1.1") # False
Version("1.1+local") # False
真正坑人的是本地版本号。PEP 440 写得很清楚:「If the specified version identifier is a public version identifier (no local version label), then the local version label of any candidate versions MUST be ignored when matching versions」。
后果就是 ==1.0 会同时接受这三个版本:
ss = SpecifierSet("==1.0")
Version("1.0") in ss # True
Version("1.0+downstream1") in ss # True
Version("1.0+ubuntu.1") in ss # True
Version("1.0.1") in ss # False
如果你在给一台机器做可复现部署,写 ==1.0 想表达「就是这个上游版本」,实际上下游打包者重新构建的 1.0+downstream1 也会被接受——而 PEP 440 自己就警告过,本地版本号「does indicate that it may not contain the exact same code as the corresponding upstream release」。
PEP 440 推荐的写法是 ===1.0,它做的是纯字符串相等:
ss = SpecifierSet("===1.0")
Version("1.0") in ss # True
Version("1.0+downstream1") in ss # False
Version("1.0+ubuntu.1") in ss # False
但 === 有个反直觉的代价
PEP 440 说 === 是给「non-PEP-440-compliant versions」准备的逃生舱。问题是:一旦你真的用它去匹配一个非规范字符串,in 运算符会抛异常而不是返回 False:
>>> Version("foobar")
packaging.version.InvalidVersion: Invalid version: 'foobar'
>>> "foobar" in Specifier("===foobar")
Traceback (most recent call last):
...
File ".../packaging/specifiers.py", line 267, in prereleases
if Version(version).is_prerelease:
packaging.version.InvalidVersion: Invalid version: 'foobar'
栈是从 __contains__ → contains → 读 self.prereleases 属性一路下来的,根因是读这个属性时先要把字符串解析成 Version,还没走到 === 的字符串比较分支就炸了。所以 === 的正确用法是只用规范字符串(比如 ===1.0),不要真拿它去兜任意历史版本号。
三、< 和 > 对同版本号的特殊排除
> 和 < 不是简单的字典序比较,它们各自排除了一类「同版本号」的变体。
PEP 440 对 > 的两条规则是:不得接受指定版本的 post-release(除非 V 本身就是 post-release),也不得接受指定版本的 local version。实测:
SpecifierSet(">1.7"): 1.7.1 命中 / 1.7.0.post1 不命中 / 1.7.post1 不命中
SpecifierSet(">=1.7"): 1.7.0.post1 命中 / 1.7.1 命中 / 1.7+local 命中
SpecifierSet(">1.0"): 1.0 不命中 / 1.0+downstream1 不命中 / 1.0.1 命中
对 < 的规则是:不得接受指定版本的 pre-release(除非指定版本本身就是 pre-release):
SpecifierSet("<1.7"): 1.6.9 命中 / 1.7.0a1 不命中 / 1.7 不命中
SpecifierSet("<1.7rc1"): 1.7a1 命中 / 1.7rc1 不命中 / 1.7 不命中
这解释了一个非常常见的求助:「我写 <1.7 就是想允许任何 1.7 之前的预览版,为什么装不上 1.7.0a1?」答案就是要把上界写到具体的 rc 号——<1.7rc1 才能放行 1.7a1。
四、预发布默认被排除(以及 25.0 与 26.x 的差异)
PEP 440 的默认规则是:「Pre-releases of any kind, including developmental releases, are implicitly excluded from all version specifiers」,除非它已经在系统里、用户明确要求、或者它是唯一能满足约束的版本。
实测:
ss = SpecifierSet(">=1.0")
Version("1.0") in ss # True
Version("1.3.0a1") in ss # False
Version("2.0rc1") in ss # False
而 filter() 会把「唯一解就是预发布」的情况救回来:
Specifier(">=1.2.3").filter(["1.2", "1.5a1"]) # ['1.5a1'] ← 没有正式版,预发布入选
Specifier(">=1.2.3").filter(["1.3", "1.5a1"]) # ['1.3'] ← 有正式版,预发布被排除
Specifier(">=1.2.3").filter(["1.3", "1.5a1"], prereleases=True) # ['1.3', '1.5a1']
⚠️ 这里有个版本坑:packaging 官方文档现在展示的是 26.x 的行为,写着 contains(item, installed=True) 可以接受已安装的预发布。但在我本机的 25.0 上,这个参数存在却不生效——SpecifierSet(">=1.0", prereleases=False).contains("1.5a1", installed=True) 返回的是 False。更彻底的问题是文档里推荐的 is_unsatisfiable()、to_range()、is_subset()、is_superset()、is_disjoint()、__replace__() 这六个方法,在 25.0 上一个都没有:
from packaging.specifiers import SpecifierSet
for api in ["is_unsatisfiable", "is_subset", "is_superset",
"is_disjoint", "to_range", "__replace__"]:
print(api, hasattr(SpecifierSet, api))
输出全是 False。所以抄 packaging 官方文档的代码前,先确认自己的版本。
五、规范原文的排序示例可以逐项复算
PEP 440 给了一段 20 个版本的未排序列表,用来定义完整排序。本机跑一遍 sorted(),结果与文档逐项相同:
from packaging.version import Version
pep = ["1.dev0", "1.0.dev456", "1.0a1", "1.0a2.dev456", "1.0a12.dev456",
"1.0a12", "1.0b1.dev456", "1.0b2", "1.0b2.post345.dev456",
"1.0b2.post345", "1.0rc1.dev456", "1.0rc1", "1.0", "1.0+abc.5",
"1.0+abc.7", "1.0+5", "1.0.post456.dev34", "1.0.post456",
"1.0.15", "1.1.dev1"]
got = [str(v) for v in sorted(Version(x) for x in pep)]
print(got == pep) # True
这段是很好的自检基线——如果你换了 packaging 版本或换到别的语言实现,用它对拍一次就知道排序行为有没有变。
顺带一个基础但反直觉的点:数字段是按数值比较,不是按字符串。PEP 440 明确要求「All numeric components MUST be interpreted and ordered according to their numeric value, not as text strings」。
Version("1.0a12") > Version("1.0a2") # True
如果按字符串比,"a12" < "a2",结果会反过来。
另外 epoch 段的威力值得单独说:Version("1!0.1") > Version("99.99") 是 True。epoch 的作用就是让项目换了编号体系之后,新版本排在所有旧版本之后。
Troubleshooting: 六个真实报错与根因
下面的报错全部是我在本机实际跑出来的,可以用原文命令复现。
报错一:ResolutionImpossible,以及那句骗人的「无可用分发」
这是本篇最有价值的一条。先看完整输出(多因果冲突的完整模板):
ERROR: Cannot install -r r-cd.txt (line 1) and -r r-cd.txt (line 2) because these package versions have conflicting dependencies.
The conflict is caused by:
demo-c 1.0 depends on demo-b>=2.0
demo-d 1.0 depends on demo-b<2.0
Additionally, some packages in these conflicts have no matching distributions available for your environment:
demo-b
To fix this you could try to:
1. loosen the range of package versions you've specified
2. remove package versions to allow pip to attempt to solve the dependency conflict
ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
现象:明明索引里有两个版本,pip 却说 demo-b「no matching distributions available for your environment」。
原因:我先用 pip 自己的命令查了候选:
python -m pip index versions demo-b --no-index --find-links tmp/wheels
# demo-b (2.0)
# Available versions: 2.0, 1.0
demo-b 明明有 1.0 和 2.0 两个真实分发文件。原因在 pip 自己的源码 factory.py:判断「这个包有没有候选」的函数长这样——
def _has_any_candidates(self, project_name: str) -> bool:
return any(
self.find_candidates(
project_name,
requirements={project_name: []}, # ← 传的是空列表
incompatibilities={},
constraint=Constraint.empty(),
prefers_installed=True,
is_satisfied_by=lambda r, c: True,
)
)
requirements 是空的,于是 find_candidates 拿到的 ireqs 也是空的,紧接着 _iter_found_candidates 的第一句就是 if not ireqs: return ()。any 空元组恒为 False,取反为 True——只要这个包的名字出现在冲突原因集合里,就一定会被列进 no_candidates。
我直接把这个私有方法调出来验证过,它确实恒返回 False,并且确实收到了空列表:
from pip._internal.resolution.resolvelib.factory import Factory
f = Factory.__new__(Factory)
print(f._has_any_candidates("demo-b")) # False
⚠️ 需要说明的是:我没有找到对应的上游 issue 记录,所以这里只陈述「pip 26.1.2 在本机的实测行为 + 源码定位」,不把它说成是社区公认的问题。
可复制解决方案:别信那句「无可用分发」,用可靠命令自己确认:
python -m pip index versions <包名>
python -m pip download --no-deps -d ./probe <包名>
真要确认是不是环境问题,再加 --python-version 和 --platform 试一次,很多「找不到分发」其实是平台或 Python 版本标签不匹配。
报错二:只有一个冲突时,报错会变成另一种形态
同样是不可满足的约束,但如果因果只有一个,pip 会走另一条分支,输出短得多,而且完全没有 Cannot install 那句话:
INFO: pip is looking at multiple versions of demo-a to determine which version is compatible with other requirements. This could take a while.
ERROR: Could not find a version that satisfies the requirement demo-b>=2.0 (from demo-a) (from versions: 1.0)
ERROR: No matching distribution found for demo-b>=2.0
原因:pip 源码里 if len(e.causes) == 1: return self._report_single_requirement_conflict(req, parent)——单因果走短路,直接把候选列表打出来。
这其实是好事:(from versions: 1.0) 直接告诉你索引里到底有什么,比多因果那个模板有用得多。所以遇到解析失败时,把冲突收敛到单一来源(比如先单独装那个冲突包)能得到更好的报错信息。
报错三:Invalid requirement 提示的分号是误导
ERROR: Invalid requirement: 'flask=>=2.0': Expected semicolon (after name with no version specifier) or end
flask=>=2.0
^ (from line 1 of r3.txt)
现象:提示说「期望一个分号」,但我的文件里本来就没有分号,也不需要分号。
原因:真实错误是 => 拼错了——>= 多了个 >。pip 的需求行解析器把 = 当成了「名字结束、进入可选的分号部分」,于是走进了「期望 semicolon」的分支。箭头位置也正好指在 => 的第二个字符上。
可复制解决方案:这类报错不要照字面修。Expected semicolon 在实践中几乎总是意味着运算符拼错。直接用 Python 校验整份文件:
from packaging.requirements import Requirement, InvalidRequirement
for i, line in enumerate(open("requirements.txt", encoding="utf-8"), 1):
line = line.split("#")[0].strip()
if not line:
continue
try:
Requirement(line)
except InvalidRequirement as e:
print(f"第 {i} 行有问题: {line!r} -> {e}")
同样的错误也出现在漏写版本号的时候:
ERROR: Invalid requirement: 'requests==': Expected semicolon (after name with no version specifier) or end
报错四:hash 校验失败(可复现的供应链护栏)
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
demo-b==2.0 from file:///.../demo_b-2.0-py3-none-any.whl (from -r r-hash.txt (line 1)):
Expected sha256 deadbeef0000000000000000000000000000000000000000000000000000000000000000
Got 905dc748929ed9057f8cea05d55fa1d1a0f5ffa2e4b1c90fd833264a15376183
原因:--hash 写错了,Got 那行才是文件真实的 sha256。
可复制解决方案:算出真实值再回填。
python -m pip download --no-deps -d ./whl "demo-b==2.0"
python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" ./whl/demo_b-2.0-py3-none-any.whl
报错五:--require-hashes 下漏写 ==
ERROR: In --require-hashes mode, all requirements must have their versions pinned with ==. These do not:
demo-b from file:///.../demo_b-2.0-py3-none-any.whl (from -r r-h2.txt (line 1))
原因:我在 requirements 里只写了 demo-b,没写版本。--require-hashes 模式下所有依赖都必须用 == 固定,包括间接依赖。
可复制解决方案:用 pip-tools 从 .in 文件生成带 hash 的锁文件,别手写:
pip-compile --generate-hashes requirements.in -o requirements.txt
报错六:非法约束字符,8 种写法全被拒
这一条不涉及 pip,直接用 packaging 就能验证。下面 8 种写法全部抛 InvalidSpecifier:
from packaging.specifiers import SpecifierSet, InvalidSpecifier
for bad in ["~=1", "==1.0.dev1.*", "==1.0+foo.*", "== ", "1.0", "=>1.0", ">=1.0.*", "~=1.0+local"]:
try:
SpecifierSet(bad)
print(f"{bad!r:16} 通过")
except InvalidSpecifier as e:
print(f"{bad!r:16} 拒绝: {e}")
'~=1' 拒绝: Invalid specifier: '~=1'
'==1.0.dev1.*' 拒绝: Invalid specifier: '==1.0.dev1.*'
'==1.0+foo.*' 拒绝: Invalid specifier: '==1.0+foo.*'
'== ' 拒绝: Invalid specifier: '=='
'1.0' 拒绝: Invalid specifier: '1.0'
'=>1.0' 拒绝: Invalid specifier: '=>1.0'
'>=1.0.*' 拒绝: Invalid specifier: '>=1.0.*'
'~=1.0+local' 拒绝: Invalid specifier: '~=1.0+local'
有两条值得注意。>=1.0.* 被拒是因为 PEP 440 只给 == 和 != 定义了 .* 前缀匹配。~=1.0+local 被拒是因为兼容发布子句里不允许本地版本号。另外 "== " 这一行的报错串里空格被吞了,显示成 '==',所以别指望从报错信息里看出你到底多打了什么。
一份能直接抄的约束速查
| 你想表达 | 不要写 | 应该写 |
|---|---|---|
| 允许补丁级升级,不跨 minor | ~=1.4 | ~=1.4.0 |
| 只允许 1.4.5 这条线的补丁 | ~=1.4.5 | ~=1.4.5.0 |
| 锁死上游原版,拒绝下游重打包 | ==1.0 | ===1.0 |
| 允许 1.7 之前的所有预览版 | <1.7 | <1.7rc1 |
| 明确允许预发布 | 依赖默认行为 | 加 --pre 或 prereleases=True |
三个静默失效的写法(没有报错,但结果是错的)
这三类问题的共同点是不会报错,所以必须在评审阶段人工看。
一、>=2.0 看起来像锁了版本,其实锁不住 major。 这是本文实测的第一条:
Version("3.1.2") in SpecifierSet(">=2.0") # True
Version("3.1.2") in SpecifierSet(">=2.0,<3") # False
二、规范文档 ≠ 你装的版本。 packaging 官网展示 26.x 的 API,本机 25.0 上六个方法全缺。抄文档之前先 hasattr。
三、--require-hashes 之外,没有任何默认的完整性校验。 pip 默认信任索引给你的字节。如果你的部署流程关心这件事,得自己加 --require-hashes 或者对制品做签名验证。
本文的边界
有几件事我没做,需要明确说明:
- 出网受限,所以没有对真实 PyPI 做端到端解析实测。六个 pip 报错全部用手工构造的本地 wheel 配合 --no-index --find-links 复现——报错是真的,但不是公网场景。
- 两个 packaging 版本并存。pip 26.1.2 vendored 的是 26.2,本文 Specifier 行为结论来自独立的 25.0。
- 没覆盖:多解释器与平台 tag 差异、--no-binary、私有索引的认证流程、以及 uv / poetry 等新解析器的行为差异。
站内另一篇讲本地分析栈底层格式的文章可以配合看:Parquet 与 Arrow 实测 同样是不联网纯本机跑数,那篇讲的是存储编码层;想理解为什么约束要写对在数据管道里尤其要紧,可以先看 DuckDB 1.5.6 直查 CSV。
联盟披露:本文不含推广内容,也不指向任何具体商品。
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):