blob: a941aeaa88d0e4b14cef4bac33fd0b6f912bd1dc [file]
import contextlib
import os
import platform
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 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"))
# 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:
return os.getenv("CIBUILDWHEEL") is not None
@contextlib.contextmanager
def _maybe_patch_toolchains() -> Generator[None, None, None]:
"""
Patch rules_python toolchains to ignore root user error
when run in a Docker container on Linux in cibuildwheel.
"""
def fmt_toolchain_args(matchobj):
suffix = "ignore_root_user_error = True"
callargs = matchobj.group(1)
# toolchain def is broken over multiple lines
if callargs.endswith("\n"):
callargs = callargs + " " + suffix + ",\n"
# toolchain def is on one line.
else:
callargs = callargs + ", " + suffix
return "python.toolchain(" + callargs + ")"
CIBW_LINUX = is_cibuildwheel() and IS_LINUX
module_bazel = Path("MODULE.bazel")
content: str = module_bazel.read_text()
try:
if CIBW_LINUX:
module_bazel.write_text(
re.sub(
r"python.toolchain\(([\w\"\s,.=]*)\)",
fmt_toolchain_args,
content,
)
)
yield
finally:
if CIBW_LINUX:
module_bazel.write_text(content)
class BazelExtension(setuptools.Extension):
"""A C/C++ extension that is defined as a Bazel BUILD target."""
def __init__(self, name: str, bazel_target: str, **kwargs: Any):
super().__init__(name=name, sources=[], **kwargs)
self.bazel_target = bazel_target
stripped_target = bazel_target.split("//")[-1]
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."""
def run(self):
for ext in self.extensions:
self.bazel_build(ext)
# explicitly call `bazel shutdown` for graceful exit
self.spawn(["bazel", "shutdown"])
def copy_extensions_to_source(self):
"""
Copy generated extensions into the source tree.
This is done in the ``bazel_build`` method, so it's not necessary to
do again in the `build_ext` base class.
"""
def bazel_build(self, ext: BazelExtension) -> None:
"""Runs the bazel build to create the package."""
temp_path = Path(self.build_temp)
# We round to the minor version, which makes rules_python
# look up the latest available patch version internally.
python_version = "{}.{}".format(*sys.version_info[:2])
bazel_argv = [
"bazel",
"run",
ext.bazel_target,
f"--symlink_prefix={temp_path / 'bazel-'}",
f"--compilation_mode={'dbg' if self.debug else 'opt'}",
# C++17 is required by nanobind
f"--cxxopt={'/std:c++17' if IS_WINDOWS else '-std=c++17'}",
f"--@rules_python//python/config_settings:python_version={python_version}",
]
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.
for library_dir in self.library_dirs:
bazel_argv.append("--linkopt=/LIBPATH:" + library_dir)
elif IS_MAC:
# C++17 needs macOS 10.14 at minimum
bazel_argv.append("--macos_minimum_os=10.14")
with _maybe_patch_toolchains():
self.spawn(bazel_argv)
# copy the Bazel build artifacts into setuptools' libdir,
# from where the wheel is built.
pkgname = "google_benchmark"
pythonroot = Path("bindings") / "python" / "google_benchmark"
srcdir = temp_path / "bazel-bin" / pythonroot
if not self.inplace:
libdir = Path(self.build_lib) / pkgname
else:
build_py = self.get_finalized_command("build_py")
libdir = Path(build_py.get_package_dir(pkgname))
for root, dirs, files in os.walk(srcdir, topdown=True):
# exclude runfiles directories and children.
dirs[:] = [d for d in dirs if "runfiles" not in d]
for f in files:
fp = Path(f)
# 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) == EXT_SUFFIX or fp.suffix == ".pyi":
shutil.copyfile(root / fp, libdir / fp)
setuptools.setup(
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(
name="google_benchmark._benchmark",
bazel_target="//bindings/python/google_benchmark:benchmark_stubgen",
py_limited_api=py_limited_api,
)
],
options=options,
)