本节目标:让同一个项目在多个 Python 版本上可验证,并设计出既快又不会误用陈旧缓存的 CI 流程。
适用版本:Python 3.12+(实测 3.14.6);uv 0.12.23
2.3 多版本 Python 矩阵与 CI 缓存
2.1 锁定了依赖,2.2 打通了源与离线安装。最后一环是「在哪些 Python 上验证、CI 怎么跑得快」。一个库项目声称支持 3.12–3.14,就必须真的在这三个版本上都跑过测试;而 CI 每次从零下载依赖,又会让反馈慢得让人放弃。本节把「矩阵」与「缓存」一起讲清。
2.3.1 requires-python 与「支持版本」的定义
pyproject.toml 里的 requires-python 是下界声明,不是「已测版本列表」:
[project]
name = "demo-app"
version = "0.1.0"
requires-python = ">=3.12"
>=3.12 只说明「低于 3.12 装不上」,并不保证在 3.13、3.14 上真的能跑。声明的范围与实测的范围是两回事:
| 概念 | 含义 | 谁来保证 |
|---|---|---|
requires-python | 允许安装的版本范围 | 元数据 |
| classifiers | 声称支持的版本(Programming Language :: Python :: 3.14) | 人写,需诚实 |
| 测试矩阵 | 实际跑过 CI 的版本 | CI 配置 |
务实做法:测试矩阵覆盖 requires-python 的下界与当前最新稳定版(3.12 与 3.14),中间版本可选。本书基线是 >=3.12,因此矩阵至少是 3.12 + 3.14。为什么矩阵不能只挑一个版本?因为每个大版本都引入了会改变行为的新特性,代码在不同版本上的可用性差异是真实的:
| 版本 | 已实测可用(本书涉及的) |
|---|---|
| 3.12 | PEP 695 泛型语法(type X = ...、class C[T]:)、itertools.batched、pathlib.Path.walk |
| 3.13 | 自由线程构建(实验性,PEP 703)、typing.TypeIs、copy.replace、warnings.deprecated |
| 3.14 | PEP 649 注解延迟求值、PEP 750 模板字符串 t"..."、PEP 758 except 免括号、compression.zstd |
用了 3.14 的 t"..." 却在 3.12 上跑,就是导入期或运行期的报错——只有矩阵能提前抓到。
2.3.2 uv 管理解释器
uv 可以自己下载和管理 Python 解释器,不必依赖系统装了几个版本。列出可用与已装的:
uv python list
cpython-3.15.0rc3-macos-aarch64-none <download available>
cpython-3.14.6-macos-aarch64-none /opt/homebrew/bin/python3.14 -> ../Cellar/python@3.14/3.14.6/bin/python3.14
cpython-3.13.16-macos-aarch64-none <download available>
cpython-3.12.15-macos-aarch64-none /Users/leting.yan/.local/share/uv/python/cpython-3.12-macos-aarch64-none/bin/python3.12
cpython-3.11.17-macos-aarch64-none <download available>
注意 3.15 那一行是 3.15.0rc3——尚未正式发布,仍是预览,不要把它写进「已支持」清单。要装某个版本并用它跑:
uv python install 3.12
uv python pin 3.12 # 写 .python-version,固定本项目默认解释器
uv run --python 3.12 pytest
uv python install 把解释器下载到 uv 的受管目录(本机的 3.12.15 就在 ~/.local/share/uv/python/ 下),与系统 Python 隔离。这让「本机没有某个 Python 版本」不再是借口。
2.3.3 多版本解析:–python-version 与 –python-platform
同一份依赖声明,在不同 Python 版本下解析结果可能不同,因为 marker 会筛选条件包。实测:
httpx>=0.27,<1
importlib-metadata>=7 ; python_version < "3.10"
在 3.9 下编译(含条件包):
uv pip compile vdep.in --python-version 3.9
Resolved 10 packages in 1.30s
httpx==0.28.1
importlib-metadata==8.7.1
# via importlib-metadata
在 3.14 下编译(条件不成立,被剔除):
uv pip compile vdep.in --python-version 3.14
Resolved 7 packages in 9ms
httpx==0.28.1
importlib-metadata 在 3.9 下被解析进来,在 3.14 下消失——因为 3.10 起标准库自带了 importlib.metadata。这就是「矩阵」在依赖层的意义:不同版本装的东西可能不一样,只在单个版本上测会漏掉问题。--python-platform 同理,用来模拟目标平台(如 linux、windows)。用 uv lock --check 可在 CI 里验证锁文件是否与 pyproject.toml 一致、无需重装:
uv lock --check
Using CPython 3.12.15
Resolved 14 packages in 14ms
2.3.4 tox 与 nox 的组织思路
在 CI 之外,本地也想一键跑多版本。tox 和 nox 是两套常见方案(本机未安装,以下仅示意,未实测)。tox 用声明式配置:
[tox]
envlist = py312, py313, py314
[testenv]
deps = -r requirements-dev.txt
commands = pytest {posargs}
nox 用 Python 脚本描述,更灵活:
import nox
@nox.session(python=["3.12", "3.13", "3.14"])
def tests(session):
session.install("-r", "requirements-dev.txt")
session.run("pytest")
两者的核心思路一致:把「版本」参数化,一个环境跑一遍全套命令。区别是 tox 配置化、nox 代码化。若团队已全面转向 uv,uv run --python 3.12 pytest 加上 shell 循环也能达到类似效果,未必需要额外工具。
2.3.5 GitHub Actions 矩阵
CI 侧最常见的表达是 GitHub Actions 的 matrix。以下 YAML 本机无 CI 环境,未实测,仅作配置示意:
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
run: uv python install ${{ matrix.python-version }}
- name: Install
run: uv sync --python ${{ matrix.python-version }} --locked
- name: Test
run: uv run --python ${{ matrix.python-version }} pytest
三个要点:fail-fast: false 让某个版本失败时其余版本仍跑完(否则你只看到第一个失败);uv sync --locked 保证锁文件与 pyproject.toml 一致、不偷偷重解析;uv run --python 显式指定每个 job 的版本。
2.3.6 缓存键设计
CI 最贵的是「重复下载」。缓存键必须同时覆盖「下载内容」与「依赖版本」,否则会误用陈旧缓存。一个合理的设计:
| 缓存对象 | 键里应包含 | 失效时机 |
|---|---|---|
| uv 缓存 | OS + Python 版本 + uv.lock 哈希 | 锁文件或版本变化 |
| pip 缓存 | OS + requirements.txt 哈希 | 依赖变化 |
| 构建产物 | 源码哈希 | 源码变化 |
关键在键里放 uv.lock 的哈希(Actions 里是 hashFiles('uv.lock')),而不是只放分支名或固定字符串:
- uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-py${{ matrix.python-version }}-
key 是精确匹配,锁文件一变(内容变了,哈希就变)缓存自然失效,绝不会拿旧依赖冒充新依赖。restore-keys 是「找不到精确匹配时的降级前缀」,让依赖变动后仍能部分复用旧缓存,避免全量重下。
2.3.7 缓存失效、并发与可复现
缓存是「加速」,绝不能是「正确性的来源」。三条纪律:
- 缓存必须可丢弃。任何一次构建都应在清空缓存后仍能得到相同结果;缓存只影响速度,不影响产物。
- 键含内容哈希。用
uv.lock/requirements.txt的哈希,而非时间戳或分支名——后者会让缓存「看起来命中、实际过期」。 - 写缓存要有并发保护。多个 job 同时写同一 key 时,Actions 只允许一个先写入成功,其余读旧值。若你自建缓存,要加锁避免半写。
另外,缓存键里的 Python 版本必须显式出现(如 py3.12)。否则 3.12 的缓存被 3.14 的 job 复用,而不同版本的 wheel 往往不同(带 cp312/cp314 ABI 标签),装了也用不了。这与 2.3.3 的解析差异是同一件事的两面:版本不同,依赖就可能不同。
2.3.8 本地预演:不装 CI 也能测多版本
不必等 CI 就能在本机验证矩阵。uv run --python 会为指定版本建(或重建)虚拟环境并执行命令。本机实测:
uv run --python 3.12 python -c "import sys; print(sys.version.split()[0])"
3.12.15
再切到 3.14:
uv run --python 3.14 python -c "import sys; print(sys.version.split()[0])"
Creating virtual environment at: .venv
Installed 12 packages in 160ms
3.14.6
注意切版本时 uv 重建了 .venv(解释器 ABI 变了,旧环境不能复用),并重装了依赖。这把「矩阵」从 CI 概念变成了本地一条命令:改了公共代码,先在 3.12 与 3.14 各跑一遍再提交,能省掉大量 CI 往返。
小结
requires-python是安装下界,不等于已测版本;测试矩阵应覆盖下界与当前最新稳定版,classifiers 要诚实。- uv 能自行下载并管理解释器(
uv python install/pin),与系统 Python 隔离;3.15 仍是rc3预览,不算已支持。 - 不同 Python 版本解析结果可能不同(如
importlib-metadata在 3.9 有、3.14 无),这正是多版本矩阵的价值。 - tox 配置化、nox 代码化,思路都是「把版本参数化、一个环境跑一遍」;本机未安装,仅示意。
- CI 矩阵用
fail-fast: false+uv sync --locked+uv run --python;YAML 本机无 CI,未实测。 - 缓存键必须含
uv.lock哈希与 Python 版本,并配restore-keys降级;缓存只加速、不承载正确性。
至此第一部分「工程基建」的核心闭环完成:搭骨架(第 1 章)→ 锁依赖(2.1)→ 管源与离线(2.2)→ 多版本与 CI(2.3)。下一节进入第 3 章,处理运行时的第一等公民——配置,看怎么用 pydantic-settings 做分层、类型安全的环境管理。
阅读导航:上一节:2.2 私有源、镜像与离线安装 · 下一节:3.1 分层配置与 pydantic-settings 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。