Add Python 3.13–3.15 and free-threading wheel support (#2314)

Update Python bindings to support Python 3.10–3.15, including free-threaded
3.14t and 3.15t builds.

Switch the wheel build to nanobind 3 split mode (`nanobind-backend>=1.0`),
which moves Python-version-specific runtime internals into `nanobind-backend`
so that stable ABI wheels work starting from Python 3.10 instead of 3.12 and
support PEP 803 (`abi3t`) on Python 3.15t+. This reduces the wheel matrix to
three wheels per platform architecture (`cp310-abi3`, `cp314-cp314t`, and
`cp315-abi3t`), and the `cp310-abi3` and `cp315-abi3t` wheels should be
forward-compatible with future Python releases.

Assisted-by: Gemini

Co-authored-by: dominic <510002+dmah42@users.noreply.github.com>
diff --git a/.bazelversion b/.bazelversion
index 2b0aa21..f9c71a5 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-8.2.1
+8.5.1
diff --git a/.github/install_bazel.sh b/.github/install_bazel.sh
index a1693b8..656b9f0 100644
--- a/.github/install_bazel.sh
+++ b/.github/install_bazel.sh
@@ -4,7 +4,7 @@
     arch="arm64"
   fi
   echo "Downloading $arch Bazel binary from GitHub releases."
-  curl -L -o $HOME/bin/bazel --create-dirs "https://github.com/bazelbuild/bazel/releases/download/8.2.0/bazel-8.2.0-linux-$arch"
+  curl -L -o $HOME/bin/bazel --create-dirs "https://github.com/bazelbuild/bazel/releases/download/8.5.1/bazel-8.5.1-linux-$arch"
   chmod +x $HOME/bin/bazel
 else
   # Bazel is installed for the correct architecture
diff --git a/.github/workflows/test_bindings.yml b/.github/workflows/test_bindings.yml
index 1bd3fb9..7f6aba3 100644
--- a/.github/workflows/test_bindings.yml
+++ b/.github/workflows/test_bindings.yml
@@ -20,7 +20,7 @@
       fail-fast: false
       matrix:
         os: [ ubuntu-latest, macos-latest, windows-latest ]
-        python-version: [ "3.10", "3.11", "3.12", "3.13" ]
+        python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t" ]
 
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -31,6 +31,7 @@
         uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
         with:
           python-version: ${{ matrix.python-version }}
+          allow-prereleases: true
       - name: Install Python bindings on ${{ matrix.os }}
         run: python -m pip install .
       - name: Run example on ${{ matrix.os }} under Python ${{ matrix.python-version }}
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 78518c5..ac0635d 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -59,9 +59,10 @@
       - name: Build wheels on ${{ matrix.os }} using cibuildwheel
         uses: pypa/cibuildwheel@e090b81e30c4d855ea63bf4b6e59204c09a101ae # v4.2.1
         env:
-          CIBW_BUILD: "cp310-* cp311-* cp312-*"
+          CIBW_ENABLE: "cpython-freethreading"
+          CIBW_BUILD: "cp310-* cp314t-* cp315t-*"
           CIBW_BUILD_FRONTEND: "build[uv]"
-          CIBW_SKIP: "*-musllinux_*"
+          CIBW_SKIP: "cp313t-* *-musllinux_*"
           CIBW_ARCHS: auto64
           CIBW_BEFORE_ALL_LINUX: bash .github/install_bazel.sh
           # Grab the rootless Bazel installation inside the container.
diff --git a/MODULE.bazel b/MODULE.bazel
index 159de1b..fa51298 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -7,7 +7,7 @@
 bazel_dep(name = "platforms", version = "0.0.11")
 bazel_dep(name = "rules_cc", version = "0.1.5")
 
-bazel_dep(name = "rules_python", version = "1.7.0", dev_dependency = True)
+bazel_dep(name = "rules_python", version = "2.4.0", dev_dependency = True)
 bazel_dep(name = "googletest", version = "1.14.0", dev_dependency = True, repo_name = "com_google_googletest")
 
 bazel_dep(name = "libpfm", version = "4.11.0.bcr.1")
@@ -28,6 +28,7 @@
 )
 python.toolchain(python_version = "3.13")
 python.toolchain(python_version = "3.14")
+python.toolchain(python_version = "3.15")
 
 pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip", dev_dependency = True)
 pip.parse(
@@ -39,4 +40,4 @@
 
 # -- bazel_dep definitions -- #
 
-bazel_dep(name = "nanobind_bazel", version = "2.12.0", dev_dependency = True)
+bazel_dep(name = "nanobind_bazel", version = "3.1.0.bcr.1", dev_dependency = True)
diff --git a/bindings/python/google_benchmark/BUILD b/bindings/python/google_benchmark/BUILD
index 2d49467..8009b14 100644
--- a/bindings/python/google_benchmark/BUILD
+++ b/bindings/python/google_benchmark/BUILD
@@ -4,10 +4,13 @@
 py_library(
     name = "google_benchmark",
     srcs = ["__init__.py"],
+    data = [":_benchmark"],
+    imports = [".."],
     visibility = ["//visibility:public"],
-    deps = [
-        ":_benchmark",
-    ],
+    deps = select({
+        "@nanobind_bazel//:with_split_mode": ["@nanobind_bazel//:nanobind_backend"],
+        "//conditions:default": [],
+    }),
 )
 
 nanobind_extension(
diff --git a/docs/python_bindings.md b/docs/python_bindings.md
index d9c5d2d..718d107 100644
--- a/docs/python_bindings.md
+++ b/docs/python_bindings.md
@@ -3,7 +3,7 @@
 Python bindings are available as wheels on [PyPI](https://pypi.org/project/google-benchmark/) for importing and 
 using Google Benchmark directly in Python. 
 Currently, pre-built wheels exist for macOS (both ARM64 and Intel x86), Linux x86-64 and 64-bit Windows.
-Supported Python versions are Python 3.8 - 3.12.
+Supported Python versions are Python 3.10 - 3.15, including free-threaded builds on Python 3.14t and 3.15t.
 
 To install Google Benchmark's Python bindings, run:
 
diff --git a/pyproject.toml b/pyproject.toml
index 24b4f74..4cdf970 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,11 +19,15 @@
     "Programming Language :: Python :: 3.11",
     "Programming Language :: Python :: 3.12",
     "Programming Language :: Python :: 3.13",
+    "Programming Language :: Python :: 3.14",
+    "Programming Language :: Python :: 3.15",
+    "Programming Language :: Python :: 3 :: Only",
+    "Programming Language :: Python :: Free Threading",
     "Topic :: Software Development :: Testing",
     "Topic :: System :: Benchmark",
 ]
 
-dynamic = ["readme", "version"]
+dynamic = ["dependencies", "readme", "version"]
 
 [dependency-groups]
 dev = ["pre-commit>=3.3.3"]
diff --git a/setup.py b/setup.py
index a92fa9b..a941aea 100644
--- a/setup.py
+++ b/setup.py
@@ -4,21 +4,35 @@
 import re
 import shutil
 import sys
+import sysconfig
 from collections.abc import Generator
 from pathlib import Path
 from typing import Any
 
 import setuptools
-from setuptools.command import build_ext
+from setuptools.command import bdist_wheel, build_ext
 
 IS_WINDOWS = platform.system() == "Windows"
 IS_MAC = platform.system() == "Darwin"
 IS_LINUX = platform.system() == "Linux"
+IS_FREE_THREADED = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
 
-# hardcoded SABI-related options. Requires that each Python interpreter
-# (hermetic or not) participating is of the same major-minor version.
-py_limited_api = sys.version_info >= (3, 12)
-options = {"bdist_wheel": {"py_limited_api": "cp312"}} if py_limited_api else {}
+# Build in nanobind 3 split mode targeting stable ABI for:
+# - Standard CPython 3.10+ (cp310-abi3)
+# - Free-threaded CPython 3.15t+ (cp315-abi3t, PEP 803)
+# Free-threaded CPython 3.14t has no stable ABI and is built in linked mode.
+SPLIT_MODE = not IS_FREE_THREADED or sys.version_info >= (3, 15)
+SABI_TARGET = "cp315t" if IS_FREE_THREADED else "cp310"
+if IS_WINDOWS:
+    EXT_SUFFIX = ".pyd"
+elif SPLIT_MODE:
+    EXT_SUFFIX = ".abi3t.so" if IS_FREE_THREADED else ".abi3.so"
+else:
+    EXT_SUFFIX = ".so"
+py_limited_api = SPLIT_MODE
+options = (
+    {"bdist_wheel": {"py_limited_api": "cp310"}} if not IS_FREE_THREADED else {}
+)
 
 
 def is_cibuildwheel() -> bool:
@@ -72,6 +86,16 @@
         self.relpath, self.target_name = stripped_target.split(":")
 
 
+class BdistWheel(bdist_wheel.bdist_wheel):
+    """Custom bdist_wheel command to handle PEP 803 abi3t wheel tags."""
+
+    def get_tag(self) -> tuple[str, str, str]:
+        python, abi, plat = super().get_tag()
+        if IS_FREE_THREADED and sys.version_info >= (3, 15):
+            return "cp315", "abi3t", plat
+        return python, abi, plat
+
+
 class BuildBazelExtension(build_ext.build_ext):
     """A command that runs Bazel to build a C/C++ extension."""
 
@@ -107,8 +131,16 @@
             f"--@rules_python//python/config_settings:python_version={python_version}",
         ]
 
-        if ext.py_limited_api:
-            bazel_argv += ["--@nanobind_bazel//:py-limited-api=cp312"]
+        if IS_FREE_THREADED:
+            bazel_argv.append(
+                "--@rules_python//python/config_settings:py_freethreaded=yes"
+            )
+
+        if SPLIT_MODE:
+            bazel_argv += [
+                f"--@nanobind_bazel//:py-limited-api={SABI_TARGET}",
+                "--@nanobind_bazel//:split-mode=True",
+            ]
 
         if IS_WINDOWS:
             # Link with python*.lib.
@@ -121,11 +153,6 @@
         with _maybe_patch_toolchains():
             self.spawn(bazel_argv)
 
-        if IS_WINDOWS:
-            suffix = ".pyd"
-        else:
-            suffix = ".abi3.so" if ext.py_limited_api else ".so"
-
         # copy the Bazel build artifacts into setuptools' libdir,
         # from where the wheel is built.
         pkgname = "google_benchmark"
@@ -146,12 +173,13 @@
                 # we do not want the bare .so file included
                 # when building for ABI3, so we require a
                 # full and exact match on the file extension.
-                if "".join(fp.suffixes) == suffix or fp.suffix == ".pyi":
+                if "".join(fp.suffixes) == EXT_SUFFIX or fp.suffix == ".pyi":
                     shutil.copyfile(root / fp, libdir / fp)
 
 
 setuptools.setup(
-    cmdclass={"build_ext": BuildBazelExtension},
+    cmdclass={"build_ext": BuildBazelExtension, "bdist_wheel": BdistWheel},
+    install_requires=["nanobind-backend>=1.0"] if SPLIT_MODE else [],
     package_data={"google_benchmark": ["py.typed", "*.pyi"]},
     ext_modules=[
         BazelExtension(