《Python编程实战》2.3 多版本 Python 矩阵与 CI 缓存

一份代码要在多个 Python 版本上跑通。本节实测 uv python list 与 --python-version 的多版本解析,对比 tox 与 nox 的组织思路,给出 GitHub Actions 矩阵配置与缓存键设计,让 CI 既不重复下载也不误用陈旧缓存。

本节目标:让同一个项目在多个 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.12PEP 695 泛型语法(type X = ...、class C[T]:)、itertools.batched、pathlib.Path.walk
3.13自由线程构建(实验性,PEP 703)、typing.TypeIs、copy.replace、warnings.deprecated
3.14PEP 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 缓存失效、并发与可复现

缓存是「加速」,绝不能是「正确性的来源」。三条纪律:

  1. 缓存必须可丢弃。任何一次构建都应在清空缓存后仍能得到相同结果;缓存只影响速度,不影响产物。
  2. 键含内容哈希。用 uv.lock / requirements.txt 的哈希,而非时间戳或分支名——后者会让缓存「看起来命中、实际过期」。
  3. 写缓存要有并发保护。多个 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时