free-threaded Python向けのPyO3拡張を配ると、CPythonのminor versionが増えるたびにwheelも増えるのでしょうか。
Python 3.15で入るabi3tを使えば、3.15以降についてはその増殖を止められます。ただし「wheelが全部で1個になる」という意味ではありません。
減らせるのはPythonバージョンとGIL有無による分岐で、OS・CPUアーキテクチャの違いは残ります。
cp315tではPythonバージョンがwheelに入る
まず、Stable ABIを使わないPyO3拡張を考えます。例として、整数を足すだけの小さな関数にします。
2026年10月2日に公開されたPython 3.15.0rc3では、3.15系列について「ここからABI変更はない」と案内されています。3.15.0 finalは10月9日予定です。
pyproject.tomlは次のようにします。
[build-system]
requires = ["maturin>=1.14,<2"]
build-backend = "maturin"
[project]
name = "wheel_probe"
version = "0.1.0"
requires-python = ">=3.15"
[tool.maturin]
features = ["pyo3/extension-module"]
Rust側は最小限です。
use pyo3::prelude::*;
#[pyfunction]
fn add(a: i64, b: i64) -> i64 {
a + b
}
#[pymodule]
fn wheel_probe(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(add, m)?)?;
Ok(())
}
Cargo.tomlの依存は次の形です。PyO3のmainドキュメントを前提にしているため、実際に試す際は利用するPyO3リリースに合わせて固定してください。
[package]
name = "wheel_probe"
version = "0.1.0"
edition = "2024"
[lib]
name = "wheel_probe"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.29", features = ["extension-module"] }
free-threaded版のPython 3.15をビルド対象にしてStable ABIを指定しなければ、PyO3はversion-specific ABIを使います。つまり成果物はcp315t向けです。
ここで問題になるのは3.16です。version-specific ABIは名前どおりCPythonの特定バージョンに結び付くため、3.16 free-threaded向けには別の成果物が必要になります。
3.15、3.16、3.17と対応範囲を広げれば、それぞれのwheelをビルドする構成になります。
abi3tにすると何が減るのか
PEP 803でPython 3.15に追加されるabi3tは、free-threaded build向けのStable ABIです。
通常のabi3は複数のCPython minor versionをまたげますが、free-threaded buildでは利用できませんでした。abi3tはその空白を埋めます。
PyO3の現行ドキュメントではabi3t featureが用意されており、Python 3.15以降について、free-threaded buildとGIL-enabled buildの両方から読み込める拡張を作れます。
設定の核はここです。
[dependencies]
pyo3 = {
version = "0.29",
features = ["extension-module", "abi3t", "abi3t-py315"]
}
ただし、ここで「一つ」という言葉には注意が必要です。
PEP 803が推奨しているwheelのABI tagはabi3.abi3tです。たとえばmacOS arm64なら、仕様上は次のような形になります。
wheel_probe-0.1.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl
このcp315は「Python 3.15専用」という意味ではありません。ここではStable ABIの最低対応バージョンを表します。
したがって、3.15を最低バージョンとして作ったStable ABIの拡張は、3.16、3.17以降にもABI互換性を持ちます。
一方、LinuxとmacOS、x86_64とarm64を同じバイナリにできるわけではありません。プラットフォームごとのwheelは依然として必要です。
「Python 3.15、3.16、3.17ごとに作る」から「対応プラットフォームごとに作る」へ、配布の軸が一つ減る、と捉えるほうが正確です。
wheelのタグはPythonから確認できます。packagingを入れて、ビルドしたwheelを渡します。
# Python 3.15+
# pip install packaging
from pathlib import Path
from packaging.utils import parse_wheel_filename
def show_wheel(path: str) -> None:
name, version, build, tags = parse_wheel_filename(
Path(path).name
)
print("name:", name)
print("version:", version)
print("build:", build)
for tag in sorted(tags, key=str):
print("tag:", tag)
show_wheel(
"dist/wheel_probe-0.1.0-cp315-abi3.abi3t-"
"macosx_11_0_arm64.whl"
)
PEP 803の圧縮ABI tagは複数の互換tagを表現するため、ファイル名だけを見るよりpackagingで分解したほうがCIで検査しやすくなります。
配布物を減らす仕組みと、対応環境を減らす仕組みは別物です。 abi3tは前者です。
3.14 free-threadedまでは一つにならない
ここが今回の境界です。
abi3tはPython 3.15で導入され、PEP 803は3.14以前への後方互換を提供していません。そのため、Python 3.14 free-threadedもサポートするなら、3.14用のversion-specificなcp314t成果物が別途必要です。
PyO3のドキュメントも、広いバージョン範囲を支える構成として、概ね次の組み合わせを示しています。
- GIL-enabledの古いPython向けに
abi3 - Python 3.14 free-threaded向けに
cp314t - Python 3.15以降向けに
abi3t
つまりabi3tを採用した瞬間、過去のfree-threaded wheelが消えるわけではありません。
一方、最低対応バージョンを3.15まで上げられるパッケージなら話はかなり単純になります。Python minor versionごとにcp315t、cp316t、cp317tと作り続ける必要がなくなります。
Python側の動作確認も複雑にする必要はありません。
import sys
import sysconfig
import wheel_probe
print(sys.version)
print("Py_GIL_DISABLED:", sysconfig.get_config_var("Py_GIL_DISABLED"))
print(wheel_probe.add(20, 22))
assert wheel_probe.add(20, 22) == 42
同じwheelを3.15以降のGIL-enabled buildとfree-threaded buildにインストールし、このテストを流します。
ただしStable ABIが保証するのはABI互換性です。Pythonのバージョンが変わっても振る舞いまで不変になる保証ではありません。公式の移行ガイドも、対応する各Pythonバージョンと、GIL-enabled/free-threadedの双方でテストするよう求めています。
wheelが一つになっても、CIのテストマトリクスまで一つにしてはいけません。
Stable ABIを選ばないほうがいい拡張もある
abi3tには代償があります。Stable ABIを使う以上、利用できるPython C APIはLimited APIに制限されます。
PyO3側も、Stable ABIではCPythonの正確なバージョンを知っていることを前提にした最適化を利用できないと説明しています。また、APIによっては最低Pythonバージョンによる制約があります。
これは単純な「Stable ABIのほうが新しいから良い」という選択ではありません。
今回のadd程度なら、Pythonオブジェクトの内部表現へ深く依存する理由はありません。こういう薄い境界の拡張は、成果物を減らせる価値のほうが分かりやすいです。
逆に、CPython固有APIを積極的に利用する拡張や、version-specific ABIでしか使えない最適化が効く処理では、先にabi3tへ寄せる理由は弱くなります。Python 3.15の公式ドキュメントも、Stable ABIへ移せない拡張についてはabi3とcp315tなどを分けて配る選択肢を明記しています。
そのため、私はwheelの個数から逆算するより、まずPythonとの境界がLimited APIの中に収まるかを確認するほうがよいと考えています。収まるならabi3tでPython minor versionの軸を消す。収まらないならversion-specific ABIを残す。この順番なら、配布を単純にするために実装側へ無理を押し込まずに済みます。
Python 3.15以降だけを対象にする小さなPyO3拡張なら、abi3tによって「Pythonバージョンごとにwheelを作る」状態から抜けられます。ただし3.14 free-threadedを残す場合、Limited API外の機能が必要な場合、あるいはversion-specificな最適化を優先する場合は別成果物が必要です。次に確認すべきなのはwheelの数ではなく、自分の拡張がどのC APIへ依存しているかです。

