From 359871bb8e991fdf737aef965ed3bb0694777b74 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 22 Apr 2026 12:16:27 -0400 Subject: [PATCH 1/6] feat: support PEP 803, free-threaded tag Support should come out in CMake 4.4 Assisted-by: Copilot:Kimi-K2.6 Signed-off-by: Henry Schreiner --- docs/configuration/index.md | 5 +- docs/guide/build.md | 3 +- docs/reference/configs.md | 7 +- src/scikit_build_core/builder/builder.py | 48 ++++++++--- src/scikit_build_core/builder/sysconfig.py | 41 ++++++--- src/scikit_build_core/builder/wheel_tag.py | 71 +++++++++++---- .../settings/skbuild_model.py | 7 +- tests/test_builder.py | 86 +++++++++++++++++++ 8 files changed, 221 insertions(+), 47 deletions(-) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index f558e45ff..899d71f28 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -372,7 +372,10 @@ wheel.py-api = "cp38" Scikit-build-core will only target ABI3 if the version of Python is equal to or newer than the one you set. `${SKBUILD_SABI_COMPONENT}` is set to -`Development.SABIModule` when targeting ABI3, and is an empty string otherwise. +`Development.SABIModule` when targeting ABI3 or ABI3T, and is an empty string +otherwise. For free-threaded Python (PEP 703), you can use `cp315t` to target +the free-threaded stable ABI, which sets `Py_TARGET_ABI3T` instead of +`Py_LIMITED_API`. If you are not using CPython at all, you can specify any version of Python is fine: diff --git a/docs/guide/build.md b/docs/guide/build.md index 487e8b7ef..679a68a00 100644 --- a/docs/guide/build.md +++ b/docs/guide/build.md @@ -189,7 +189,8 @@ The three new items here (compared to SDists) are the [compatibility tags][]: `py3` for pure Python wheels, or `py312` (etc) for compiled wheels. - `abi tag`: The interpreter ABI this was built for. `none` for pure Python wheels or compiled wheels that don't use the Python API, `abi3` for stable ABI - / limited API wheels, and `cp312` (etc) for normal compiled wheels. + / limited API wheels, `abi3t` for free-threaded stable ABI wheels, and `cp312` + (etc) for normal compiled wheels. - `platform tag`: This is the platform the wheel is valid on, such as `any`, `linux_x86_64`, or `manylinux_2_17_x86_64`. diff --git a/docs/reference/configs.md b/docs/reference/configs.md index c2738a64e..d1b690672 100644 --- a/docs/reference/configs.md +++ b/docs/reference/configs.md @@ -605,8 +605,11 @@ print(mk_skbuild_docs()) You can also set this to "cp38" to enable the CPython 3.8+ Stable ABI / Limited API (only on CPython and if the version is sufficient, - otherwise this has no effect). Or you can set it to "py3" or "py2.py3" to - ignore Python ABI compatibility. The ABI tag is inferred from this tag. + otherwise this has no effect). For free-threaded Python, you can use + "cp315t" to enable the free-threaded stable ABI (only on CPython + free-threaded builds and if the version is sufficient). Or you can set + it to "py3" or "py2.py3" to ignore Python ABI compatibility. The ABI + tag is inferred from this tag. This value is used to construct ``SKBUILD_SABI_COMPONENT`` CMake variable. ``` diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py index 65bc9f637..7088f0028 100644 --- a/src/scikit_build_core/builder/builder.py +++ b/src/scikit_build_core/builder/builder.py @@ -209,27 +209,41 @@ def configure( ) cache_config["SKBUILD_PROJECT_VERSION_FULL"] = str(version) + py_api = self.settings.wheel.py_api + ft_abi = False if limited_api is None: - if self.settings.wheel.py_api.startswith("cp3"): - target_minor_version = int(self.settings.wheel.py_api[3:]) + if py_api.startswith("cp3") and py_api.endswith("t"): + target_minor_version = int(py_api[3:-1]) + ft_abi = ( + sys.implementation.name == "cpython" + and sysconfig.get_config_var("Py_GIL_DISABLED") + and target_minor_version <= sys.version_info.minor + ) + limited_api = ft_abi + elif py_api.startswith("cp3"): + target_minor_version = int(py_api[3:]) limited_api = target_minor_version <= sys.version_info.minor else: limited_api = False if limited_api and sys.implementation.name != "cpython": limited_api = False + ft_abi = False logger.info("PyPy doesn't support the Limited API, ignoring") - if limited_api and sysconfig.get_config_var("Py_GIL_DISABLED"): + if limited_api and sysconfig.get_config_var("Py_GIL_DISABLED") and not ft_abi: limited_api = False logger.info( - "Free-threaded Python doesn't support the Limited API currently, ignoring" + "Free-threaded Python doesn't support the classic Limited API, ignoring" ) python_library = get_python_library(self.config.env, abi3=False) - python_sabi_library = ( - get_python_library(self.config.env, abi3=True) if limited_api else None - ) + python_sabi_library = None + if limited_api: + if ft_abi: + python_sabi_library = get_python_library(self.config.env, abi3t=True) + else: + python_sabi_library = get_python_library(self.config.env, abi3=True) python_include_dir = get_python_include_dir() numpy_include_dir = get_numpy_include_dir() @@ -265,7 +279,9 @@ def configure( if numpy_include_dir: cache_config[f"{prefix}_NumPy_INCLUDE_DIR"] = numpy_include_dir - cache_config["SKBUILD_SOABI"] = get_soabi(self.config.env, abi3=limited_api) + cache_config["SKBUILD_SOABI"] = get_soabi( + self.config.env, abi3=(limited_api and not ft_abi), abi3t=ft_abi + ) # Allow CMakeLists to detect this is supposed to be a limited ABI build cache_config["SKBUILD_SABI_COMPONENT"] = ( @@ -273,12 +289,16 @@ def configure( ) # Allow users to detect the version requested in settings - py_api = self.settings.wheel.py_api - cache_config["SKBUILD_SABI_VERSION"] = ( - f"{py_api[2]}.{py_api[3:]}" - if limited_api and py_api.startswith("cp") - else "" - ) + if limited_api and py_api.startswith("cp"): + version_str = py_api[2:] + if version_str.endswith("t"): + version_str = version_str[:-1] + cache_config["SKBUILD_SABI_VERSION"] = f"{version_str[0]}.{version_str[1:]}" + else: + cache_config["SKBUILD_SABI_VERSION"] = "" + + if ft_abi: + cache_config["Py_TARGET_ABI3T"] = "1" if cache_entries: cache_config.update(cache_entries) diff --git a/src/scikit_build_core/builder/sysconfig.py b/src/scikit_build_core/builder/sysconfig.py index 781681862..0a7879be3 100644 --- a/src/scikit_build_core/builder/sysconfig.py +++ b/src/scikit_build_core/builder/sysconfig.py @@ -44,7 +44,9 @@ def __dir__() -> list[str]: return __all__ -def get_python_library(env: Mapping[str, str], *, abi3: bool = False) -> Path | None: +def get_python_library( + env: Mapping[str, str], *, abi3: bool = False, abi3t: bool = False +) -> Path | None: # When cross-compiling, check DIST_EXTRA_CONFIG first config_file = env.get("DIST_EXTRA_CONFIG", None) if config_file and Path(config_file).is_file(): @@ -53,21 +55,24 @@ def get_python_library(env: Mapping[str, str], *, abi3: bool = False) -> Path | result = cp.get("build_ext", "library_dirs", fallback="") if result: logger.info("Reading DIST_EXTRA_CONFIG:build_ext.library_dirs={}", result) - minor = "" if abi3 else sys.version_info[1] - if env.get("SETUPTOOLS_EXT_SUFFIX", "").endswith("t.pyd"): - return Path(result) / f"python3{minor}t.lib" - return Path(result) / f"python3{minor}.lib" + minor = "" if (abi3 or abi3t) else sys.version_info[1] + suffix = "t" if abi3t else "" + return Path(result) / f"python3{minor}{suffix}.lib" libdirstr = sysconfig.get_config_var("LIBDIR") ldlibrarystr = sysconfig.get_config_var("LDLIBRARY") librarystr = sysconfig.get_config_var("LIBRARY") - if abi3: + if abi3 or abi3t: + if abi3t and sysconfig.get_config_var("Py_GIL_DISABLED"): + replacement = f"python3{sys.version_info[1]}t" + target = "python3t" + else: + replacement = f"python3{sys.version_info[1]}" + target = "python3" if ldlibrarystr is not None: - ldlibrarystr = ldlibrarystr.replace( - f"python3{sys.version_info[1]}", "python3" - ) + ldlibrarystr = ldlibrarystr.replace(replacement, target) if librarystr is not None: - librarystr = librarystr.replace(f"python3{sys.version_info[1]}", "python3") + librarystr = librarystr.replace(replacement, target) libdir: Path | None = libdirstr and Path(libdirstr) ldlibrary: Path | None = ldlibrarystr and Path(ldlibrarystr) @@ -158,7 +163,11 @@ def get_cmake_platform(env: Mapping[str, str] | None) -> str: return PLAT_TO_CMAKE.get(plat, plat) -def get_soabi(env: Mapping[str, str], *, abi3: bool = False) -> str: +def get_soabi( + env: Mapping[str, str], *, abi3: bool = False, abi3t: bool = False +) -> str: + if abi3t: + return "" if sysconfig.get_platform().startswith("win") else "abi3t" if abi3: return "" if sysconfig.get_platform().startswith("win") else "abi3" @@ -226,6 +235,11 @@ def info_print( get_python_library(os.environ, abi3=True), color=color, ) + rich_print( + "{bold}Detected ABI3T Python Library:", + get_python_library(os.environ, abi3t=True), + color=color, + ) rich_print( "{bold}Detected Python Include Directory:", get_python_include_dir(), @@ -251,6 +265,11 @@ def info_print( get_soabi(os.environ, abi3=True), color=color, ) + rich_print( + "{color}Detected ABI3T SOABI:", + get_soabi(os.environ, abi3t=True), + color=color, + ) rich_print( "{bold}Detected ABI flags:", get_abi_flags(), diff --git a/src/scikit_build_core/builder/wheel_tag.py b/src/scikit_build_core/builder/wheel_tag.py index 5b81e9b0e..0ae263bc7 100644 --- a/src/scikit_build_core/builder/wheel_tag.py +++ b/src/scikit_build_core/builder/wheel_tag.py @@ -99,30 +99,69 @@ def compute_best( if py_api: pyvers_new = py_api.split(".") - if all(x.startswith("cp3") and x[3:].isdecimal() for x in pyvers_new): - if len(pyvers_new) != 1: - msg = "Unexpected py-api, must be a single cp version (e.g. cp39), not {py_api}" - raise AssertionError(msg) + if all( + ( + x.startswith("cp3") + and x[3:].isdecimal() + and not sysconfig.get_config_var("Py_GIL_DISABLED") + ) + or ( + x.startswith("cp3") + and len(x) > 4 + and x[3:-1].isdecimal() + and x.endswith("t") + and sysconfig.get_config_var("Py_GIL_DISABLED") + ) + for x in pyvers_new + ): if root_is_purelib: msg = f"Unexpected py-api, since platlib is set to false, must be Pythonless (e.g. py2.py3), not {py_api}" raise AssertionError(msg) - minor = int(pyvers_new[0][3:]) - if ( - sys.implementation.name == "cpython" - and minor <= sys.version_info.minor - and not sysconfig.get_config_var("Py_GIL_DISABLED") + # Separate classic and free-threaded tags + classic_tags = [ + x for x in pyvers_new if x.startswith("cp3") and x[3:].isdecimal() + ] + ft_tags = [ + x + for x in pyvers_new + if x.startswith("cp3") + and len(x) > 4 + and x[3:-1].isdecimal() + and x.endswith("t") + ] + + if sys.implementation.name == "cpython" and sysconfig.get_config_var( + "Py_GIL_DISABLED" ): - pyvers = pyvers_new - abi = "abi3" - else: - msg = "Ignoring py-api, not a CPython interpreter ({}) or version (3.{}) is too high or free-threaded" - logger.debug(msg, sys.implementation.name, minor) + # Free-threaded: only accept cp3XXt tags + if ft_tags: + target = ft_tags[0] + minor = int(target[3:-1]) + if minor <= sys.version_info.minor: + pyvers = [target] + abi = "abi3t" + else: + msg = "Ignoring py-api, version (3.{}) is too high" + logger.debug(msg, minor) + # Classic CPython + elif classic_tags: + target = classic_tags[0] + minor = int(target[3:]) + if ( + sys.implementation.name == "cpython" + and minor <= sys.version_info.minor + ): + pyvers = [target] + abi = "abi3" + else: + msg = "Ignoring py-api, not a CPython interpreter ({}) or version (3.{}) is too high" + logger.debug(msg, sys.implementation.name, minor) elif all(x.startswith("py") and x[2:].isdecimal() for x in pyvers_new): pyvers = pyvers_new abi = "none" else: - msg = f"Unexpected py-api, must be abi3 (e.g. cp39) or Pythonless (e.g. py2.py3), not {py_api}" + msg = f"Unexpected py-api, must be abi3 (e.g. cp39), abi3t (e.g. cp315t), or Pythonless (e.g. py2.py3), not {py_api}" raise AssertionError(msg) return cls(pyvers=pyvers, abis=[abi], archs=plats, build_tag=build_tag) @@ -174,7 +213,7 @@ def as_tags_set(self) -> frozenset[packaging.tags.Tag]: parser.add_argument( "--abi", default="", - help="Specify py-api, like 'cp38' or 'py3'", + help="Specify py-api, like 'cp38', 'cp315t', or 'py3'", ) parser.add_argument( "--purelib", diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py index 63cf7474c..567fa0f97 100644 --- a/src/scikit_build_core/settings/skbuild_model.py +++ b/src/scikit_build_core/settings/skbuild_model.py @@ -289,8 +289,11 @@ class WheelSettings: You can also set this to "cp38" to enable the CPython 3.8+ Stable ABI / Limited API (only on CPython and if the version is sufficient, - otherwise this has no effect). Or you can set it to "py3" or "py2.py3" to - ignore Python ABI compatibility. The ABI tag is inferred from this tag. + otherwise this has no effect). For free-threaded Python, you can use + "cp315t" to enable the free-threaded stable ABI (only on CPython + free-threaded builds and if the version is sufficient). Or you can set + it to "py3" or "py2.py3" to ignore Python ABI compatibility. The ABI + tag is inferred from this tag. This value is used to construct ``SKBUILD_SABI_COMPONENT`` CMake variable. """ diff --git a/tests/test_builder.py b/tests/test_builder.py index d6de6fe4b..770cc6d4c 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os import platform import pprint @@ -96,6 +98,10 @@ def test_get_python_library_xcompile(tmp_path): assert lib2 assert lib2 == Path("C:\\Python\\libs\\python3.lib") + lib3 = get_python_library(env, abi3t=True) + assert lib3 + assert lib3 == Path("C:\\Python\\libs\\python3t.lib") + @pytest.mark.parametrize("archs", [["x86_64"], ["arm64", "universal2"]]) def test_builder_macos_arch(monkeypatch, archs): @@ -269,6 +275,68 @@ def test_wheel_tag_with_abi_darwin(monkeypatch): assert str(tags) == "py2.py3-none-macosx_10_10_x86_64" +def test_wheel_tag_with_abi3t_darwin(monkeypatch): + """Test cp315t free-threaded stable ABI tag.""" + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: "t" if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(sys, "implementation", SimpleNamespace(name="cpython")) + monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10") + monkeypatch.setattr(platform, "mac_ver", lambda: ("10.9.2", "", "")) + + class VersionInfo: + def __init__( + self, + major: int, + minor: int, + micro: int = 0, + releaselevel: str = "final", + serial: int = 0, + ) -> None: + self.major = major + self.minor = minor + self.micro = micro + self.releaselevel = releaselevel + self.serial = serial + + def __getitem__(self, index: int) -> int | str: + return (self.major, self.minor, self.micro, self.releaselevel, self.serial)[ + index + ] + + def __ge__(self, other: tuple[int, ...]) -> bool: + return (self.major, self.minor, self.micro) >= other[:3] + + monkeypatch.setattr(sys, "version_info", VersionInfo(3, 15)) + + tags = WheelTag.compute_best(["x86_64"], py_api="cp315t") + assert str(tags) == "cp315t-abi3t-macosx_10_10_x86_64" + + tags = WheelTag.compute_best(["x86_64"], py_api="cp316t") + assert "abi3t" not in str(tags) + assert "cp316t" not in str(tags) + + +def test_wheel_tag_with_abi3t_invalid_on_classic(monkeypatch): + """Test cp315t is rejected on classic (non-free-threaded) Python.""" + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: None if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10") + monkeypatch.setattr(platform, "mac_ver", lambda: ("10.9.2", "", "")) + + with pytest.raises(AssertionError, match="Unexpected py-api"): + WheelTag.compute_best(["x86_64"], py_api="cp315t") + + def test_wheel_tag_host_platform_override(monkeypatch): """Test that _PYTHON_HOST_PLATFORM environment variable overrides platform detection.""" get_config_var = sysconfig.get_config_var @@ -288,6 +356,24 @@ def test_wheel_tag_host_platform_override(monkeypatch): assert str(tags) == "py3-none-emscripten_4_0_9_wasm32" +def test_get_soabi_abi3t(monkeypatch): + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: "t" if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + + from scikit_build_core.builder.sysconfig import get_soabi + + assert get_soabi({}, abi3t=True) == ( + "" if sysconfig.get_platform().startswith("win") else "abi3t" + ) + assert get_soabi({}, abi3=True) == ( + "" if sysconfig.get_platform().startswith("win") else "abi3" + ) + + def test_generator_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(tmp_path) builder = Builder( From 6f8a1e5f33ba45547d76524e4c69aceeeafe69cc Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 23 Apr 2026 14:30:37 -0400 Subject: [PATCH 2/6] chore: minor updates from review Signed-off-by: Henry Schreiner --- docs/configuration/index.md | 4 ++-- src/scikit_build_core/builder/builder.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 899d71f28..73c4ff990 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -374,8 +374,8 @@ Scikit-build-core will only target ABI3 if the version of Python is equal to or newer than the one you set. `${SKBUILD_SABI_COMPONENT}` is set to `Development.SABIModule` when targeting ABI3 or ABI3T, and is an empty string otherwise. For free-threaded Python (PEP 703), you can use `cp315t` to target -the free-threaded stable ABI, which sets `Py_TARGET_ABI3T` instead of -`Py_LIMITED_API`. +the free-threaded stable ABI, which sets `Py_TARGET_ABI3T` (if using CMake +4.4+). If you are not using CPython at all, you can specify any version of Python is fine: diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py index 7088f0028..a960b3a61 100644 --- a/src/scikit_build_core/builder/builder.py +++ b/src/scikit_build_core/builder/builder.py @@ -226,7 +226,7 @@ def configure( else: limited_api = False - if limited_api and sys.implementation.name != "cpython": + if (limited_api or ft_abi) and sys.implementation.name != "cpython": limited_api = False ft_abi = False logger.info("PyPy doesn't support the Limited API, ignoring") From 560cf5abbe05e6989067448e3274a6f3a7984a40 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 23 Apr 2026 14:46:49 -0400 Subject: [PATCH 3/6] chore: address review feedback on ft ABI logic - Add `_PyTag` helper class to `wheel_tag.py` to make the complex `all()` ABI tag checks readable; refactor `compute_best` to use it - Restructure the `limited_api` block in `builder.py`: move the `cp3*t` branch inside the `cp3` check, add a warning when the free-threaded stable ABI is requested but conditions aren't met, and move the classic-on-free-threaded warning into the inner branch - Promote the inline `VersionInfo` test helper to module level in `tests/test_builder.py` and add a `from_str()` classmethod Assisted-by: Copilot:claude-sonnet-4.6 Signed-off-by: Henry Schreiner --- src/scikit_build_core/builder/builder.py | 43 +++++++--- src/scikit_build_core/builder/wheel_tag.py | 94 ++++++++++++---------- tests/test_builder.py | 55 +++++++------ 3 files changed, 117 insertions(+), 75 deletions(-) diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py index a960b3a61..f2eca5f02 100644 --- a/src/scikit_build_core/builder/builder.py +++ b/src/scikit_build_core/builder/builder.py @@ -212,20 +212,41 @@ def configure( py_api = self.settings.wheel.py_api ft_abi = False if limited_api is None: - if py_api.startswith("cp3") and py_api.endswith("t"): - target_minor_version = int(py_api[3:-1]) - ft_abi = ( - sys.implementation.name == "cpython" - and sysconfig.get_config_var("Py_GIL_DISABLED") - and target_minor_version <= sys.version_info.minor - ) - limited_api = ft_abi - elif py_api.startswith("cp3"): - target_minor_version = int(py_api[3:]) - limited_api = target_minor_version <= sys.version_info.minor + if py_api.startswith("cp3"): + if py_api.endswith("t"): + # Free-threaded stable ABI (PEP 803 / abi3t) + target_minor_version = int(py_api[3:-1]) + if ( + sys.implementation.name == "cpython" + and sysconfig.get_config_var("Py_GIL_DISABLED") + and target_minor_version <= sys.version_info.minor + ): + ft_abi = True + limited_api = True + else: + limited_api = False + logger.info( + "py-api {} requires free-threaded CPython >= 3.{}, ignoring", + py_api, + target_minor_version, + ) + else: + # Classic stable ABI (abi3) + target_minor_version = int(py_api[3:]) + if sys.implementation.name != "cpython": + limited_api = False + logger.info("PyPy doesn't support the Limited API, ignoring") + elif sysconfig.get_config_var("Py_GIL_DISABLED"): + limited_api = False + logger.info( + "Free-threaded Python doesn't support the classic Limited API, ignoring" + ) + else: + limited_api = target_minor_version <= sys.version_info.minor else: limited_api = False + # Handle externally-set limited_api (e.g. from setuptools) if (limited_api or ft_abi) and sys.implementation.name != "cpython": limited_api = False ft_abi = False diff --git a/src/scikit_build_core/builder/wheel_tag.py b/src/scikit_build_core/builder/wheel_tag.py index 0ae263bc7..c40ab7cb4 100644 --- a/src/scikit_build_core/builder/wheel_tag.py +++ b/src/scikit_build_core/builder/wheel_tag.py @@ -24,6 +24,35 @@ def __dir__() -> list[str]: return __all__ +class _PyTag: + """Helper for interrogating a single Python ABI tag like 'cp39' or 'cp315t'.""" + + def __init__(self, tag: str) -> None: + self._tag = tag + + @property + def is_classic_abi3(self) -> bool: + return self._tag.startswith("cp3") and self._tag[3:].isdecimal() + + @property + def is_ft_abi3(self) -> bool: + return ( + self._tag.startswith("cp3") + and self._tag.endswith("t") + and len(self._tag) > 4 + and self._tag[3:-1].isdecimal() + ) + + @property + def minor(self) -> int: + if self.is_ft_abi3: + return int(self._tag[3:-1]) + return int(self._tag[3:]) + + def __str__(self) -> str: + return self._tag + + @dataclasses.dataclass(frozen=True) class WheelTag: pyvers: list[str] @@ -99,64 +128,47 @@ def compute_best( if py_api: pyvers_new = py_api.split(".") + pytags = [_PyTag(x) for x in pyvers_new] + gil_disabled = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) if all( - ( - x.startswith("cp3") - and x[3:].isdecimal() - and not sysconfig.get_config_var("Py_GIL_DISABLED") - ) - or ( - x.startswith("cp3") - and len(x) > 4 - and x[3:-1].isdecimal() - and x.endswith("t") - and sysconfig.get_config_var("Py_GIL_DISABLED") - ) - for x in pyvers_new + (t.is_classic_abi3 and not gil_disabled) + or (t.is_ft_abi3 and gil_disabled) + for t in pytags ): if root_is_purelib: msg = f"Unexpected py-api, since platlib is set to false, must be Pythonless (e.g. py2.py3), not {py_api}" raise AssertionError(msg) - # Separate classic and free-threaded tags - classic_tags = [ - x for x in pyvers_new if x.startswith("cp3") and x[3:].isdecimal() - ] - ft_tags = [ - x - for x in pyvers_new - if x.startswith("cp3") - and len(x) > 4 - and x[3:-1].isdecimal() - and x.endswith("t") - ] - - if sys.implementation.name == "cpython" and sysconfig.get_config_var( - "Py_GIL_DISABLED" - ): + classic_tags = [t for t in pytags if t.is_classic_abi3] + ft_tags = [t for t in pytags if t.is_ft_abi3] + + if sys.implementation.name == "cpython" and gil_disabled: # Free-threaded: only accept cp3XXt tags if ft_tags: target = ft_tags[0] - minor = int(target[3:-1]) - if minor <= sys.version_info.minor: - pyvers = [target] + if target.minor <= sys.version_info.minor: + pyvers = [str(target)] abi = "abi3t" else: - msg = "Ignoring py-api, version (3.{}) is too high" - logger.debug(msg, minor) + logger.debug( + "Ignoring py-api, version (3.{}) is too high", + target.minor, + ) # Classic CPython elif classic_tags: target = classic_tags[0] - minor = int(target[3:]) if ( sys.implementation.name == "cpython" - and minor <= sys.version_info.minor + and target.minor <= sys.version_info.minor ): - pyvers = [target] + pyvers = [str(target)] abi = "abi3" else: - msg = "Ignoring py-api, not a CPython interpreter ({}) or version (3.{}) is too high" - logger.debug(msg, sys.implementation.name, minor) + logger.debug( + "Ignoring py-api, not a CPython interpreter ({}) or version (3.{}) is too high", + sys.implementation.name, + target.minor, + ) elif all(x.startswith("py") and x[2:].isdecimal() for x in pyvers_new): pyvers = pyvers_new abi = "none" @@ -221,5 +233,5 @@ def as_tags_set(self) -> frozenset[packaging.tags.Tag]: help="Specify a non-platlib (pure) tag", ) args = parser.parse_args() - tag = WheelTag.compute_best(args.archs, args.abi, root_is_purelib=args.purelib) - print(tag) # noqa: T201 + comp_tag = WheelTag.compute_best(args.archs, args.abi, root_is_purelib=args.purelib) + print(comp_tag) # noqa: T201 diff --git a/tests/test_builder.py b/tests/test_builder.py index 770cc6d4c..8a97d05a1 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -30,6 +30,38 @@ ) +class VersionInfo: + """Minimal sys.version_info replacement for monkeypatching in tests.""" + + def __init__( + self, + major: int, + minor: int, + micro: int = 0, + releaselevel: str = "final", + serial: int = 0, + ) -> None: + self.major = major + self.minor = minor + self.micro = micro + self.releaselevel = releaselevel + self.serial = serial + + @classmethod + def from_str(cls, version: str) -> VersionInfo: + major, minor, *rest = (int(x) for x in version.split(".")) + micro = rest[0] if rest else 0 + return cls(major, minor, micro) + + def __getitem__(self, index: int) -> int | str: + return (self.major, self.minor, self.micro, self.releaselevel, self.serial)[ + index + ] + + def __ge__(self, other: tuple[int, ...]) -> bool: + return (self.major, self.minor, self.micro) >= other[:3] + + # The envvar_higher case shouldn't happen, but the compiler should cause the # correct failure @pytest.mark.parametrize( @@ -288,29 +320,6 @@ def test_wheel_tag_with_abi3t_darwin(monkeypatch): monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10") monkeypatch.setattr(platform, "mac_ver", lambda: ("10.9.2", "", "")) - class VersionInfo: - def __init__( - self, - major: int, - minor: int, - micro: int = 0, - releaselevel: str = "final", - serial: int = 0, - ) -> None: - self.major = major - self.minor = minor - self.micro = micro - self.releaselevel = releaselevel - self.serial = serial - - def __getitem__(self, index: int) -> int | str: - return (self.major, self.minor, self.micro, self.releaselevel, self.serial)[ - index - ] - - def __ge__(self, other: tuple[int, ...]) -> bool: - return (self.major, self.minor, self.micro) >= other[:3] - monkeypatch.setattr(sys, "version_info", VersionInfo(3, 15)) tags = WheelTag.compute_best(["x86_64"], py_api="cp315t") From 9d6d097c96fa85195105bdfd71d6b383da7746e4 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 29 Apr 2026 14:12:06 -0400 Subject: [PATCH 4/6] fix: ensure mismatched ABI requests don't interfere Assisted-by: Copilot:GPT-5.4 Signed-off-by: Henry Schreiner --- src/scikit_build_core/builder/wheel_tag.py | 14 +++++++----- tests/test_builder.py | 26 ++++++++++++++++++---- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/scikit_build_core/builder/wheel_tag.py b/src/scikit_build_core/builder/wheel_tag.py index c40ab7cb4..098411c54 100644 --- a/src/scikit_build_core/builder/wheel_tag.py +++ b/src/scikit_build_core/builder/wheel_tag.py @@ -130,11 +130,7 @@ def compute_best( pyvers_new = py_api.split(".") pytags = [_PyTag(x) for x in pyvers_new] gil_disabled = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) - if all( - (t.is_classic_abi3 and not gil_disabled) - or (t.is_ft_abi3 and gil_disabled) - for t in pytags - ): + if all(t.is_classic_abi3 or t.is_ft_abi3 for t in pytags): if root_is_purelib: msg = f"Unexpected py-api, since platlib is set to false, must be Pythonless (e.g. py2.py3), not {py_api}" raise AssertionError(msg) @@ -154,6 +150,10 @@ def compute_best( "Ignoring py-api, version (3.{}) is too high", target.minor, ) + elif classic_tags: + logger.debug( + "Ignoring py-api, free-threaded Python doesn't support the classic Stable ABI" + ) # Classic CPython elif classic_tags: target = classic_tags[0] @@ -169,6 +169,10 @@ def compute_best( sys.implementation.name, target.minor, ) + elif ft_tags: + logger.debug( + "Ignoring py-api, free-threaded CPython is required for abi3t" + ) elif all(x.startswith("py") and x[2:].isdecimal() for x in pyvers_new): pyvers = pyvers_new abi = "none" diff --git a/tests/test_builder.py b/tests/test_builder.py index 8a97d05a1..5bffb9222 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -330,8 +330,25 @@ def test_wheel_tag_with_abi3t_darwin(monkeypatch): assert "cp316t" not in str(tags) -def test_wheel_tag_with_abi3t_invalid_on_classic(monkeypatch): - """Test cp315t is rejected on classic (non-free-threaded) Python.""" +def test_wheel_tag_with_classic_abi3_ignored_on_free_threaded(monkeypatch): + """Test classic abi3 requests fall back to default tags on free-threaded Python.""" + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: "t" if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10") + monkeypatch.setattr(platform, "mac_ver", lambda: ("10.9.2", "", "")) + + default_tags = WheelTag.compute_best(["x86_64"]) + tags = WheelTag.compute_best(["x86_64"], py_api="cp38") + assert tags == default_tags + + +def test_wheel_tag_with_abi3t_ignored_on_classic(monkeypatch): + """Test cp315t falls back to default tags on classic (non-free-threaded) Python.""" get_config_var = sysconfig.get_config_var monkeypatch.setattr( sysconfig, @@ -342,8 +359,9 @@ def test_wheel_tag_with_abi3t_invalid_on_classic(monkeypatch): monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10") monkeypatch.setattr(platform, "mac_ver", lambda: ("10.9.2", "", "")) - with pytest.raises(AssertionError, match="Unexpected py-api"): - WheelTag.compute_best(["x86_64"], py_api="cp315t") + default_tags = WheelTag.compute_best(["x86_64"]) + tags = WheelTag.compute_best(["x86_64"], py_api="cp315t") + assert tags == default_tags def test_wheel_tag_host_platform_override(monkeypatch): From a657445364686c0df0b10be21a6a15898b720349 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 30 Apr 2026 15:51:05 -0400 Subject: [PATCH 5/6] fix: support free-threaded correctly when limited api requested Signed-off-by: Henry Schreiner --- src/scikit_build_core/builder/builder.py | 9 ++- tests/test_builder.py | 96 ++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py index f2eca5f02..fe3c5ddb2 100644 --- a/src/scikit_build_core/builder/builder.py +++ b/src/scikit_build_core/builder/builder.py @@ -210,7 +210,8 @@ def configure( cache_config["SKBUILD_PROJECT_VERSION_FULL"] = str(version) py_api = self.settings.wheel.py_api - ft_abi = False + gil_disabled = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + ft_abi = limited_api is True and gil_disabled if limited_api is None: if py_api.startswith("cp3"): if py_api.endswith("t"): @@ -218,7 +219,7 @@ def configure( target_minor_version = int(py_api[3:-1]) if ( sys.implementation.name == "cpython" - and sysconfig.get_config_var("Py_GIL_DISABLED") + and gil_disabled and target_minor_version <= sys.version_info.minor ): ft_abi = True @@ -236,7 +237,7 @@ def configure( if sys.implementation.name != "cpython": limited_api = False logger.info("PyPy doesn't support the Limited API, ignoring") - elif sysconfig.get_config_var("Py_GIL_DISABLED"): + elif gil_disabled: limited_api = False logger.info( "Free-threaded Python doesn't support the classic Limited API, ignoring" @@ -252,7 +253,7 @@ def configure( ft_abi = False logger.info("PyPy doesn't support the Limited API, ignoring") - if limited_api and sysconfig.get_config_var("Py_GIL_DISABLED") and not ft_abi: + if limited_api and gil_disabled and not ft_abi: limited_api = False logger.info( "Free-threaded Python doesn't support the classic Limited API, ignoring" diff --git a/tests/test_builder.py b/tests/test_builder.py index 5bffb9222..ed64fc340 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -26,6 +26,7 @@ CMakeSettings, CMakeSettingsDefine, ScikitBuildSettings, + SearchSettings, WheelSettings, ) @@ -187,6 +188,101 @@ def test_build_tool_args(): ) +def configure_builder_with_limited_api( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + limited_api: bool | None, + py_api: str = "", +) -> str: + source_dir = tmp_path / "src" + source_dir.mkdir() + + config = CMaker( + CMake(Version("3.30"), Path("cmake")), + source_dir=source_dir, + build_dir=tmp_path / "build", + build_type="Release", + ) + monkeypatch.setattr(config, "configure", unittest.mock.Mock()) + + builder = Builder( + settings=ScikitBuildSettings( + search=SearchSettings(site_packages=False), + wheel=WheelSettings(py_api=py_api), + ), + config=config, + ) + monkeypatch.setattr(Builder, "_get_entry_point_search_path", lambda *_: {}) + + builder.configure(defines={}, limited_api=limited_api) + return config.init_cache_file.read_text(encoding="utf-8") + + +def patch_cpython_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + implementation = vars(sys.implementation).copy() + implementation["name"] = "cpython" + monkeypatch.setattr( + sys, + "implementation", + SimpleNamespace(**implementation), + ) + + +def test_builder_limited_api_override_classic(tmp_path, monkeypatch): + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: None if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + patch_cpython_runtime(monkeypatch) + + cache = configure_builder_with_limited_api(tmp_path, monkeypatch, limited_api=True) + + expected_soabi = "" if sysconfig.get_platform().startswith("win") else "abi3" + assert "Development.SABIModule" in cache + assert f"set(SKBUILD_SOABI [===[{expected_soabi}]===] CACHE STRING" in cache + assert "Py_TARGET_ABI3T" not in cache + + +def test_builder_limited_api_override_free_threaded(tmp_path, monkeypatch): + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: "t" if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + patch_cpython_runtime(monkeypatch) + + cache = configure_builder_with_limited_api(tmp_path, monkeypatch, limited_api=True) + + expected_soabi = "" if sysconfig.get_platform().startswith("win") else "abi3t" + assert "Development.SABIModule" in cache + assert f"set(SKBUILD_SOABI [===[{expected_soabi}]===] CACHE STRING" in cache + assert "set(Py_TARGET_ABI3T [===[1]===] CACHE STRING" in cache + + +def test_builder_limited_api_auto_free_threaded(tmp_path, monkeypatch): + get_config_var = sysconfig.get_config_var + monkeypatch.setattr( + sysconfig, + "get_config_var", + lambda x: "t" if x == "Py_GIL_DISABLED" else get_config_var(x), + ) + patch_cpython_runtime(monkeypatch) + monkeypatch.setattr(sys, "version_info", VersionInfo(3, 15)) + + cache = configure_builder_with_limited_api( + tmp_path, monkeypatch, limited_api=None, py_api="cp315t" + ) + + expected_soabi = "" if sysconfig.get_platform().startswith("win") else "abi3t" + assert "Development.SABIModule" in cache + assert f"set(SKBUILD_SOABI [===[{expected_soabi}]===] CACHE STRING" in cache + assert "set(Py_TARGET_ABI3T [===[1]===] CACHE STRING" in cache + + @pytest.mark.parametrize( ("minver", "archs", "answer"), [ From 22a83614cef0811bfbc9d5584916de3398134cf0 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 30 Apr 2026 17:34:25 -0400 Subject: [PATCH 6/6] refactor: simplify logic Assisted-by: Copilot:claude-sonnet-4.6 Signed-off-by: Henry Schreiner --- src/scikit_build_core/builder/builder.py | 102 +++++++++++------------ 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py index fe3c5ddb2..afcae657c 100644 --- a/src/scikit_build_core/builder/builder.py +++ b/src/scikit_build_core/builder/builder.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +import enum import os import re import shlex @@ -35,6 +36,12 @@ DIR = Path(__file__).parent.resolve() +class _SabiMode(enum.Enum): + NONE = enum.auto() + ABI3 = enum.auto() + ABI3T = enum.auto() + + def __dir__() -> list[str]: return __all__ @@ -211,61 +218,46 @@ def configure( py_api = self.settings.wheel.py_api gil_disabled = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) - ft_abi = limited_api is True and gil_disabled - if limited_api is None: - if py_api.startswith("cp3"): - if py_api.endswith("t"): - # Free-threaded stable ABI (PEP 803 / abi3t) - target_minor_version = int(py_api[3:-1]) - if ( - sys.implementation.name == "cpython" - and gil_disabled - and target_minor_version <= sys.version_info.minor - ): - ft_abi = True - limited_api = True - else: - limited_api = False - logger.info( - "py-api {} requires free-threaded CPython >= 3.{}, ignoring", - py_api, - target_minor_version, - ) + + sabi = _SabiMode.NONE + if limited_api is True: + # Handle externally-set limited_api (e.g. from setuptools) + if sys.implementation.name != "cpython": + logger.info("PyPy doesn't support the Limited API, ignoring") + elif gil_disabled: + sabi = _SabiMode.ABI3T + else: + sabi = _SabiMode.ABI3 + elif limited_api is None and py_api.startswith("cp3"): + target_minor_version = int(py_api[3:].rstrip("t")) + if sys.implementation.name != "cpython": + logger.info("py-api {} requires CPython, ignoring") + elif py_api.endswith("t"): + # Free-threaded stable ABI (PEP 803 / abi3t) + if gil_disabled and target_minor_version <= sys.version_info.minor: + sabi = _SabiMode.ABI3T else: - # Classic stable ABI (abi3) - target_minor_version = int(py_api[3:]) - if sys.implementation.name != "cpython": - limited_api = False - logger.info("PyPy doesn't support the Limited API, ignoring") - elif gil_disabled: - limited_api = False - logger.info( - "Free-threaded Python doesn't support the classic Limited API, ignoring" - ) - else: - limited_api = target_minor_version <= sys.version_info.minor + logger.info( + "py-api {} requires free-threaded CPython >= 3.{}, ignoring", + py_api, + target_minor_version, + ) else: - limited_api = False - - # Handle externally-set limited_api (e.g. from setuptools) - if (limited_api or ft_abi) and sys.implementation.name != "cpython": - limited_api = False - ft_abi = False - logger.info("PyPy doesn't support the Limited API, ignoring") - - if limited_api and gil_disabled and not ft_abi: - limited_api = False - logger.info( - "Free-threaded Python doesn't support the classic Limited API, ignoring" - ) + # Classic stable ABI (abi3) + target_minor_version = int(py_api[3:]) + if gil_disabled: + logger.info( + "Free-threaded Python doesn't support the classic Limited API, ignoring" + ) + elif target_minor_version <= sys.version_info.minor: + sabi = _SabiMode.ABI3 python_library = get_python_library(self.config.env, abi3=False) python_sabi_library = None - if limited_api: - if ft_abi: - python_sabi_library = get_python_library(self.config.env, abi3t=True) - else: - python_sabi_library = get_python_library(self.config.env, abi3=True) + if sabi == _SabiMode.ABI3T: + python_sabi_library = get_python_library(self.config.env, abi3t=True) + elif sabi == _SabiMode.ABI3: + python_sabi_library = get_python_library(self.config.env, abi3=True) python_include_dir = get_python_include_dir() numpy_include_dir = get_numpy_include_dir() @@ -302,16 +294,18 @@ def configure( cache_config[f"{prefix}_NumPy_INCLUDE_DIR"] = numpy_include_dir cache_config["SKBUILD_SOABI"] = get_soabi( - self.config.env, abi3=(limited_api and not ft_abi), abi3t=ft_abi + self.config.env, + abi3=(sabi == _SabiMode.ABI3), + abi3t=(sabi == _SabiMode.ABI3T), ) # Allow CMakeLists to detect this is supposed to be a limited ABI build cache_config["SKBUILD_SABI_COMPONENT"] = ( - "Development.SABIModule" if limited_api else "" + "Development.SABIModule" if sabi != _SabiMode.NONE else "" ) # Allow users to detect the version requested in settings - if limited_api and py_api.startswith("cp"): + if sabi != _SabiMode.NONE and py_api.startswith("cp"): version_str = py_api[2:] if version_str.endswith("t"): version_str = version_str[:-1] @@ -319,7 +313,7 @@ def configure( else: cache_config["SKBUILD_SABI_VERSION"] = "" - if ft_abi: + if sabi == _SabiMode.ABI3T: cache_config["Py_TARGET_ABI3T"] = "1" if cache_entries: