Modernize setup.py, extend Python bindings CI (#1535)

distutils is deprecated and will be removed in Python 3.12, so this
commit modernizes the Python bindings `setup.py` file in order to
future-proof the code.

On top of this, type hints were added for all of the convenience
functions to make static type checking adoption easier in the future,
if desired.

A context manager was added to temporarily write the Python include
path to the Bazel WORKSPACE file - but unlike previously, the
WORKSPACE file is reverted to its previous state after the build to not
produce changes on every rebuild.

Lastly, the Python bindings test matrix was extended to all major
platforms to create a more complete picture of the current state of
the bindings, especially with regards to upcoming wheel builds.
This commit is contained in:
Nicholas Junge 2023-02-03 10:47:02 +01:00 committed by GitHub
parent 5e78bedfb0
commit f59d021ebc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 90 additions and 87 deletions

View File

@ -8,17 +8,21 @@ on:
jobs:
python_bindings:
runs-on: ubuntu-latest
name: Test GBM Python bindings on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: 3.8
- name: Install benchmark
python-version: 3.11
- name: Install GBM Python bindings on ${{ matrix.os}}
run:
python setup.py install
- name: Run example bindings
python -m pip install wheel .
- name: Run bindings example on ${{ matrix.os }}
run:
python bindings/python/google_benchmark/example.py

View File

@ -18,5 +18,5 @@ pip3_install(
new_local_repository(
name = "python_headers",
build_file = "@//bindings/python:python_headers.BUILD",
path = "/usr/include/python3.6", # May be overwritten by setup.py.
path = "<PYTHON_INCLUDE_PATH>", # May be overwritten by setup.py.
)

119
setup.py
View File

@ -1,44 +1,38 @@
import contextlib
import os
import posixpath
import platform
import re
import shutil
import sys
import sysconfig
from pathlib import Path
from typing import List
from distutils import sysconfig
import setuptools
from setuptools.command import build_ext
HERE = os.path.dirname(os.path.abspath(__file__))
PYTHON_INCLUDE_PATH_PLACEHOLDER = "<PYTHON_INCLUDE_PATH>"
IS_WINDOWS = platform.system() == "Windows"
IS_MAC = platform.system() == "Darwin"
IS_WINDOWS = sys.platform.startswith("win")
def _get_long_description(fp: str) -> str:
with open(fp, "r", encoding="utf-8") as f:
return f.read()
with open("README.md", "r", encoding="utf-8") as fp:
long_description = fp.read()
def _get_version(fp: str) -> str:
"""Parse a version string from a file."""
with open(fp, "r") as f:
for line in f:
if "__version__" in line:
delim = '"'
return line.split(delim)[1]
raise RuntimeError(f"could not find a version string in file {fp!r}.")
def _get_version():
"""Parse the version string from __init__.py."""
with open(
os.path.join(HERE, "bindings", "python", "google_benchmark", "__init__.py")
) as init_file:
try:
version_line = next(
line for line in init_file if line.startswith("__version__")
)
except StopIteration:
raise ValueError("__version__ not defined in __init__.py")
else:
namespace = {}
exec(version_line, namespace) # pylint: disable=exec-used
return namespace["__version__"]
def _parse_requirements(path):
with open(os.path.join(HERE, path)) as requirements:
def _parse_requirements(fp: str) -> List[str]:
with open(fp) as requirements:
return [
line.rstrip()
for line in requirements
@ -46,15 +40,36 @@ def _parse_requirements(path):
]
@contextlib.contextmanager
def temp_fill_include_path(fp: str):
"""Temporarily set the Python include path in a file."""
with open(fp, "r+") as f:
try:
content = f.read()
replaced = content.replace(
PYTHON_INCLUDE_PATH_PLACEHOLDER,
Path(sysconfig.get_paths()['include']).as_posix(),
)
f.seek(0)
f.write(replaced)
f.truncate()
yield
finally:
# revert to the original content after exit
f.seek(0)
f.write(content)
f.truncate()
class BazelExtension(setuptools.Extension):
"""A C/C++ extension that is defined as a Bazel BUILD target."""
def __init__(self, name, bazel_target):
def __init__(self, name: str, bazel_target: str):
super().__init__(name=name, sources=[])
self.bazel_target = bazel_target
self.relpath, self.target_name = posixpath.relpath(bazel_target, "//").split(
":"
)
setuptools.Extension.__init__(self, name, sources=[])
stripped_target = bazel_target.split("//")[-1]
self.relpath, self.target_name = stripped_target.split(":")
class BuildBazelExtension(build_ext.build_ext):
@ -65,36 +80,24 @@ class BuildBazelExtension(build_ext.build_ext):
self.bazel_build(ext)
build_ext.build_ext.run(self)
def bazel_build(self, ext):
def bazel_build(self, ext: BazelExtension):
"""Runs the bazel build to create the package."""
with open("WORKSPACE", "r") as workspace:
workspace_contents = workspace.read()
with open("WORKSPACE", "w") as workspace:
workspace.write(
re.sub(
r'(?<=path = ").*(?=", # May be overwritten by setup\.py\.)',
sysconfig.get_python_inc().replace(os.path.sep, posixpath.sep),
workspace_contents,
)
)
if not os.path.exists(self.build_temp):
os.makedirs(self.build_temp)
with temp_fill_include_path("WORKSPACE"):
temp_path = Path(self.build_temp)
bazel_argv = [
"bazel",
"build",
ext.bazel_target,
"--symlink_prefix=" + os.path.join(self.build_temp, "bazel-"),
"--compilation_mode=" + ("dbg" if self.debug else "opt"),
str(ext.bazel_target),
f"--symlink_prefix={temp_path / 'bazel-'}",
f"--compilation_mode={'dbg' if self.debug else 'opt'}",
]
if IS_WINDOWS:
# Link with python*.lib.
for library_dir in self.library_dirs:
bazel_argv.append("--linkopt=/LIBPATH:" + library_dir)
elif sys.platform == "darwin" and platform.machine() == "x86_64":
elif IS_MAC and platform.machine() == "x86_64":
bazel_argv.append("--macos_minimum_os=10.9")
# ARCHFLAGS is always set by cibuildwheel before macOS wheel builds.
@ -106,14 +109,10 @@ class BuildBazelExtension(build_ext.build_ext):
self.spawn(bazel_argv)
shared_lib_suffix = '.dll' if IS_WINDOWS else '.so'
ext_bazel_bin_path = os.path.join(
self.build_temp, 'bazel-bin',
ext.relpath, ext.target_name + shared_lib_suffix)
ext_name = ext.target_name + shared_lib_suffix
ext_bazel_bin_path = temp_path / 'bazel-bin' / ext.relpath / ext_name
ext_dest_path = self.get_ext_fullpath(ext.name)
ext_dest_dir = os.path.dirname(ext_dest_path)
if not os.path.exists(ext_dest_dir):
os.makedirs(ext_dest_dir)
ext_dest_path = Path(self.get_ext_fullpath(ext.name))
shutil.copyfile(ext_bazel_bin_path, ext_dest_path)
# explicitly call `bazel shutdown` for graceful exit
@ -122,10 +121,10 @@ class BuildBazelExtension(build_ext.build_ext):
setuptools.setup(
name="google_benchmark",
version=_get_version(),
version=_get_version("bindings/python/google_benchmark/__init__.py"),
url="https://github.com/google/benchmark",
description="A library to benchmark code snippets.",
long_description=long_description,
long_description=_get_long_description("README.md"),
long_description_content_type="text/markdown",
author="Google",
author_email="benchmark-py@google.com",