From 9a537512c141c050e89f7b6e251f6dfb9b0898a7 Mon Sep 17 00:00:00 2001 From: Matthieu Darbois Date: Sun, 3 Jul 2022 20:00:00 +0200 Subject: [PATCH] feat: add support for `py3-none-{platform}` wheels (#1151) * feature: add support for `py3-none-{platform}` wheels This extends the mechanism introduced in #1091 for `abi3` wheels. Most of the mentions to `abi3` have been removed and replaced by a more generic `compatible_wheel`. This allows to build a wheel `foo-0.1-py3-none-win_amd64.whl` only once and still test with every configured python. * Add integration test for py3-none abi wheels * Fix expected_wheels for py3-none abi * Limit test to three pythons and check for certain log messages * chore: add some comments to explain filter process Co-authored-by: Joe Rickerby Co-authored-by: Henry Schreiner --- cibuildwheel/linux.py | 12 +-- cibuildwheel/macos.py | 12 +-- cibuildwheel/util.py | 37 +++++--- cibuildwheel/windows.py | 12 +-- setup.cfg | 4 +- test/test_abi_variants.py | 179 ++++++++++++++++++++++++++++++++++++++ test/test_limited_api.py | 50 ----------- test/utils.py | 34 ++++---- unit_test/utils_test.py | 44 +++++++++- 9 files changed, 284 insertions(+), 100 deletions(-) create mode 100644 test/test_abi_variants.py delete mode 100644 test/test_limited_api.py diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index b278d48bb..70f91c100 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -13,7 +13,7 @@ from .util import ( BuildSelector, NonPlatformWheelError, - find_compatible_abi3_wheel, + find_compatible_wheel, get_build_verbosity_extra_flags, prepare_command, read_python_configs, @@ -180,13 +180,13 @@ def build_on_docker( ) sys.exit(1) - abi3_wheel = find_compatible_abi3_wheel(built_wheels, config.identifier) - if abi3_wheel: + compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) + if compatible_wheel: log.step_end() print( - f"\nFound previously built wheel {abi3_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." + f"\nFound previously built wheel {compatible_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." ) - repaired_wheels = [abi3_wheel] + repaired_wheels = [compatible_wheel] else: if build_options.before_build: @@ -307,7 +307,7 @@ def build_on_docker( docker.call(["rm", "-rf", venv_dir]) # move repaired wheels to output - if abi3_wheel is None: + if compatible_wheel is None: docker.call(["mkdir", "-p", container_output_dir]) docker.call(["mv", *repaired_wheels, container_output_dir]) built_wheels.extend( diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index 2da719eaf..22213de80 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -24,7 +24,7 @@ call, detect_ci_provider, download, - find_compatible_abi3_wheel, + find_compatible_wheel, get_build_verbosity_extra_flags, get_pip_version, install_certifi_script, @@ -323,13 +323,13 @@ def build(options: Options, tmp_path: Path) -> None: build_options.build_frontend, ) - abi3_wheel = find_compatible_abi3_wheel(built_wheels, config.identifier) - if abi3_wheel: + compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) + if compatible_wheel: log.step_end() print( - f"\nFound previously built wheel {abi3_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." + f"\nFound previously built wheel {compatible_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." ) - repaired_wheel = abi3_wheel + repaired_wheel = compatible_wheel else: if build_options.before_build: log.step("Running before_build...") @@ -536,7 +536,7 @@ def build(options: Options, tmp_path: Path) -> None: ) # we're all done here; move it to output (overwrite existing) - if abi3_wheel is None: + if compatible_wheel is None: try: (build_options.output_dir / repaired_wheel.name).unlink() except FileNotFoundError: diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 9bc151cc1..d3f42bda0 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -53,7 +53,7 @@ "MANYLINUX_ARCHS", "call", "shell", - "find_compatible_abi3_wheel", + "find_compatible_wheel", "format_safe", "prepare_command", "get_build_verbosity_extra_flags", @@ -575,36 +575,47 @@ def virtualenv( T = TypeVar("T", bound=PurePath) -def find_compatible_abi3_wheel(wheels: Sequence[T], identifier: str) -> Optional[T]: +def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> Optional[T]: """ - Finds an ABI3 wheel in `wheels` compatible with the Python interpreter - specified by `identifier`. + Finds a wheel with an abi3 or a none ABI tag in `wheels` compatible with the Python interpreter + specified by `identifier` that is previously built. """ interpreter, platform = identifier.split("-") - if not interpreter.startswith("cp3"): - return None for wheel in wheels: _, _, _, tags = parse_wheel_filename(wheel.name) for tag in tags: - if tag.abi != "abi3": - continue - if not tag.interpreter.startswith("cp3"): + if tag.abi == "abi3": + # ABI3 wheels must start with cp3 for impl and tag + if not (interpreter.startswith("cp3") and tag.interpreter.startswith("cp3")): + continue + elif tag.abi == "none": + # CPythonless wheels must include py3 tag + if tag.interpreter[:3] != "py3": + continue + else: + # Other types of wheels are not detected, this is looking for previously built wheels. continue - if int(tag.interpreter[3:]) > int(interpreter[3:]): + + if tag.interpreter != "py3" and int(tag.interpreter[3:]) > int(interpreter[3:]): + # If a minor version number is given, it has to be lower than the current one. continue + if platform.startswith(("manylinux", "musllinux", "macosx")): - # Linux, macOS + # Linux, macOS require the beginning and ending match (macos/manylinux version doesn't need to) os_, arch = platform.split("_", 1) if not tag.platform.startswith(os_): continue - if not tag.platform.endswith("_" + arch): + if not tag.platform.endswith(f"_{arch}"): continue else: - # Windows + # Windows should exactly match if not tag.platform == platform: continue + + # If all the filters above pass, then the wheel is a previously built compatible wheel. return wheel + return None diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 0ad2e2ae8..46b8bf900 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -23,7 +23,7 @@ NonPlatformWheelError, call, download, - find_compatible_abi3_wheel, + find_compatible_wheel, get_build_verbosity_extra_flags, get_pip_version, prepare_command, @@ -279,13 +279,13 @@ def build(options: Options, tmp_path: Path) -> None: build_options.build_frontend, ) - abi3_wheel = find_compatible_abi3_wheel(built_wheels, config.identifier) - if abi3_wheel: + compatible_wheel = find_compatible_wheel(built_wheels, config.identifier) + if compatible_wheel: log.step_end() print( - f"\nFound previously built wheel {abi3_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." + f"\nFound previously built wheel {compatible_wheel.name}, that's compatible with {config.identifier}. Skipping build step..." ) - repaired_wheel = abi3_wheel + repaired_wheel = compatible_wheel else: # run the before_build command if build_options.before_build: @@ -420,7 +420,7 @@ def build(options: Options, tmp_path: Path) -> None: shell(test_command_prepared, cwd="c:\\", env=virtualenv_env) # we're all done here; move it to output (remove if already exists) - if abi3_wheel is None: + if compatible_wheel is None: shutil.move(str(repaired_wheel), build_options.output_dir) built_wheels.append(build_options.output_dir / repaired_wheel.name) diff --git a/setup.cfg b/setup.cfg index 4f91bf926..838631e04 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,8 +56,8 @@ console_scripts = cibuildwheel = resources/* [flake8] -extend-ignore = E203,E501,B950 -extend-select = B,B9 +extend-ignore = E203,E501,B950,B023 +extend-select = B9 application-import-names = cibuildwheel exclude = cibuildwheel/resources/, diff --git a/test/test_abi_variants.py b/test/test_abi_variants.py new file mode 100644 index 000000000..932a2ec01 --- /dev/null +++ b/test/test_abi_variants.py @@ -0,0 +1,179 @@ +import textwrap + +from . import test_projects, utils + +limited_api_project = test_projects.new_c_project( + setup_py_add=textwrap.dedent( + r""" + cmdclass = {} + extension_kwargs = {} + if sys.version_info[:2] >= (3, 8): + from wheel.bdist_wheel import bdist_wheel as _bdist_wheel + + class bdist_wheel_abi3(_bdist_wheel): + def finalize_options(self): + _bdist_wheel.finalize_options(self) + self.root_is_pure = False + + def get_tag(self): + python, abi, plat = _bdist_wheel.get_tag(self) + return python, "abi3", plat + + cmdclass["bdist_wheel"] = bdist_wheel_abi3 + extension_kwargs["define_macros"] = [("Py_LIMITED_API", "0x03080000")] + extension_kwargs["py_limited_api"] = True + """ + ), + setup_py_extension_args_add="**extension_kwargs", + setup_py_setup_args_add="cmdclass=cmdclass", +) + + +def test_abi3(tmp_path): + project_dir = tmp_path / "project" + limited_api_project.generate(project_dir) + + # build the wheels + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_SKIP": "pp* ", # PyPy does not have a Py_LIMITED_API equivalent + }, + ) + + # check that the expected wheels are produced + expected_wheels = [ + w.replace("cp38-cp38", "cp38-abi3") + for w in utils.expected_wheels("spam", "0.1.0") + if "-pp" not in w and "-cp39" not in w and "-cp310" not in w and "-cp311" not in w + ] + assert set(actual_wheels) == set(expected_wheels) + + +ctypes_project = test_projects.TestProject() +ctypes_project.files["setup.py"] = textwrap.dedent( + """ + from setuptools import setup, Extension + + from distutils.command.build_ext import build_ext as _build_ext + class CTypesExtension(Extension): pass + class build_ext(_build_ext): + def build_extension(self, ext): + self._ctypes = isinstance(ext, CTypesExtension) + return super().build_extension(ext) + + def get_export_symbols(self, ext): + if self._ctypes: + return ext.export_symbols + return super().get_export_symbols(ext) + + def get_ext_filename(self, ext_name): + if self._ctypes: + return ext_name + '.so' + return super().get_ext_filename(ext_name) + + from wheel.bdist_wheel import bdist_wheel as _bdist_wheel + class bdist_wheel_abi_none(_bdist_wheel): + def finalize_options(self): + _bdist_wheel.finalize_options(self) + self.root_is_pure = False + + def get_tag(self): + python, abi, plat = _bdist_wheel.get_tag(self) + return "py3", "none", plat + + setup( + name="ctypesexample", + version="1.0.0", + py_modules = ["ctypesexample.summing"], + ext_modules=[ + CTypesExtension( + "ctypesexample.csumlib", + ["ctypesexample/csumlib.c"], + ), + ], + cmdclass={'build_ext': build_ext, 'bdist_wheel': bdist_wheel_abi_none}, + ) + """ +) +ctypes_project.files["ctypesexample/csumlib.c"] = textwrap.dedent( + """ + #ifdef _WIN32 + #define LIBRARY_API __declspec(dllexport) + #else + #define LIBRARY_API + #endif + + #include + + + LIBRARY_API double *add_vec3(double *a, double *b) + { + double *res = malloc(sizeof(double) * 3); + + for (int i = 0; i < 3; ++i) + { + res[i] = a[i] + b[i]; + } + + return res; + } + """ +) +ctypes_project.files["ctypesexample/summing.py"] = textwrap.dedent( + """ + import ctypes + import pathlib + + # path of the shared library + libfile = pathlib.Path(__file__).parent / "csumlib.so" + csumlib = ctypes.CDLL(str(libfile)) + + type_vec3 = ctypes.POINTER(ctypes.c_double * 3) + + csumlib.add_vec3.restype = type_vec3 + csumlib.add_vec3.argtypes = [type_vec3, type_vec3] + def add(a: list, b: list) -> list: + a_p = (ctypes.c_double * 3)(*a) + b_p = (ctypes.c_double * 3)(*b) + r_p = csumlib.add_vec3(a_p,b_p) + + return [l for l in r_p.contents] + """ +) + +ctypes_project.files["test/add_test.py"] = textwrap.dedent( + """ + import ctypesexample.summing + + def test(): + a = [1, 2, 3] + b = [4, 5, 6] + assert ctypesexample.summing.add(a, b) == [5, 7, 9] + """ +) + + +def test_abi_none(tmp_path, capfd): + project_dir = tmp_path / "project" + ctypes_project.generate(project_dir) + + # build the wheels + actual_wheels = utils.cibuildwheel_run( + project_dir, + add_env={ + "CIBW_TEST_REQUIRES": "pytest", + "CIBW_TEST_COMMAND": "pytest {project}/test", + # limit the number of builds for test performance reasons + "CIBW_BUILD": "cp38-* cp310-* pp39-*", + }, + ) + + # check that the expected wheels are produced + expected_wheels = utils.expected_wheels("ctypesexample", "1.0.0", python_abi_tags=["py3-none"]) + assert set(actual_wheels) == set(expected_wheels) + + # check that each wheel was built once, and reused + captured = capfd.readouterr() + assert "Building wheel..." in captured.out + assert "Found previously built wheel" in captured.out diff --git a/test/test_limited_api.py b/test/test_limited_api.py deleted file mode 100644 index ea173bf55..000000000 --- a/test/test_limited_api.py +++ /dev/null @@ -1,50 +0,0 @@ -import textwrap - -from . import test_projects, utils - -limited_api_project = test_projects.new_c_project( - setup_py_add=textwrap.dedent( - r""" - cmdclass = {} - extension_kwargs = {} - if sys.version_info[:2] >= (3, 8): - from wheel.bdist_wheel import bdist_wheel as _bdist_wheel - - class bdist_wheel_abi3(_bdist_wheel): - def finalize_options(self): - _bdist_wheel.finalize_options(self) - self.root_is_pure = False - - def get_tag(self): - python, abi, plat = _bdist_wheel.get_tag(self) - return python, "abi3", plat - - cmdclass["bdist_wheel"] = bdist_wheel_abi3 - extension_kwargs["define_macros"] = [("Py_LIMITED_API", "0x03080000")] - extension_kwargs["py_limited_api"] = True - """ - ), - setup_py_extension_args_add="**extension_kwargs", - setup_py_setup_args_add="cmdclass=cmdclass", -) - - -def test(tmp_path): - project_dir = tmp_path / "project" - limited_api_project.generate(project_dir) - - # build the wheels - actual_wheels = utils.cibuildwheel_run( - project_dir, - add_env={ - "CIBW_SKIP": "pp* ", # PyPy does not have a Py_LIMITED_API equivalent - }, - ) - - # check that the expected wheels are produced - expected_wheels = [ - w.replace("cp38-cp38", "cp38-abi3") - for w in utils.expected_wheels("spam", "0.1.0") - if "-pp" not in w and "-cp39" not in w and "-cp310" not in w and "-cp311" not in w - ] - assert set(actual_wheels) == set(expected_wheels) diff --git a/test/utils.py b/test/utils.py index b6212021d..d325be684 100644 --- a/test/utils.py +++ b/test/utils.py @@ -113,6 +113,7 @@ def expected_wheels( musllinux_versions=None, macosx_deployment_target="10.9", machine_arch=None, + python_abi_tags=None, ): """ Returns a list of expected wheels from a run of cibuildwheel. @@ -139,21 +140,22 @@ def expected_wheels( if musllinux_versions is None: musllinux_versions = ["musllinux_1_1"] - python_abi_tags = [ - "cp36-cp36m", - "cp37-cp37m", - "cp38-cp38", - "cp39-cp39", - "cp310-cp310", - "cp311-cp311", - ] + if python_abi_tags is None: + python_abi_tags = [ + "cp36-cp36m", + "cp37-cp37m", + "cp38-cp38", + "cp39-cp39", + "cp310-cp310", + "cp311-cp311", + ] - if machine_arch in ["x86_64", "AMD64", "x86", "aarch64"]: - python_abi_tags += ["pp37-pypy37_pp73", "pp38-pypy38_pp73", "pp39-pypy39_pp73"] + if machine_arch in ["x86_64", "AMD64", "x86", "aarch64"]: + python_abi_tags += ["pp37-pypy37_pp73", "pp38-pypy38_pp73", "pp39-pypy39_pp73"] - if platform == "macos" and machine_arch == "arm64": - # currently, arm64 macs are only supported by cp39, cp310 & cp311 - python_abi_tags = ["cp39-cp39", "cp310-cp310", "cp311-cp311"] + if platform == "macos" and machine_arch == "arm64": + # currently, arm64 macs are only supported by cp39, cp310 & cp311 + python_abi_tags = ["cp39-cp39", "cp310-cp310", "cp311-cp311"] wheels = [] @@ -185,10 +187,10 @@ def expected_wheels( ) elif platform == "windows": - if python_abi_tag.startswith("cp"): - platform_tags = ["win32", "win_amd64"] - else: + if python_abi_tag.startswith("pp"): platform_tags = ["win_amd64"] + else: + platform_tags = ["win32", "win_amd64"] elif platform == "macos": if python_abi_tag == "cp39-cp39" and machine_arch == "arm64": diff --git a/unit_test/utils_test.py b/unit_test/utils_test.py index 41e376fa3..7f949a739 100644 --- a/unit_test/utils_test.py +++ b/unit_test/utils_test.py @@ -1,4 +1,8 @@ -from cibuildwheel.util import format_safe, prepare_command +from pathlib import PurePath + +import pytest + +from cibuildwheel.util import find_compatible_wheel, format_safe, prepare_command def test_format_safe(): @@ -46,3 +50,41 @@ def test_prepare_command(): prepare_command("{a}{a,b}{b:.2e}{c}{d%s}{e:3}{f[0]}", a="42", b="3.14159") == "42{a,b}{b:.2e}{c}{d%s}{e:3}{f[0]}" ) + + +@pytest.mark.parametrize( + "wheel,identifier", + ( + ("foo-0.1-cp38-abi3-win_amd64.whl", "cp310-win_amd64"), + ("foo-0.1-cp38-abi3-macosx_11_0_x86_64.whl", "cp310-macosx_x86_64"), + ("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-manylinux_x86_64"), + ("foo-0.1-cp38-abi3-musllinux_1_1_x86_64.whl", "cp310-musllinux_x86_64"), + ("foo-0.1-py2.py3-none-win_amd64.whl", "cp310-win_amd64"), + ("foo-0.1-py2.py3-none-win_amd64.whl", "pp310-win_amd64"), + ("foo-0.1-py3-none-win_amd64.whl", "cp310-win_amd64"), + ("foo-0.1-py38-none-win_amd64.whl", "cp310-win_amd64"), + ("foo-0.1-py38-none-win_amd64.whl", "pp310-win_amd64"), + ), +) +def test_find_compatible_wheel_found(wheel: str, identifier: str): + wheel_ = PurePath(wheel) + found = find_compatible_wheel([wheel_], identifier) + assert found is wheel_ + + +@pytest.mark.parametrize( + "wheel,identifier", + ( + ("foo-0.1-cp38-abi3-win_amd64.whl", "cp310-win32"), + ("foo-0.1-cp38-abi3-win_amd64.whl", "cp37-win_amd64"), + ("foo-0.1-cp38-abi3-macosx_11_0_x86_64.whl", "cp310-macosx_universal2"), + ("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-musllinux_x86_64"), + ("foo-0.1-cp38-abi3-musllinux_1_1_x86_64.whl", "cp310-manylinux_x86_64"), + ("foo-0.1-py2-none-win_amd64.whl", "cp310-win_amd64"), + ("foo-0.1-py38-none-win_amd64.whl", "cp37-win_amd64"), + ("foo-0.1-py38-none-win_amd64.whl", "pp37-win_amd64"), + ("foo-0.1-cp38-cp38-win_amd64.whl", "cp310-win_amd64"), + ), +) +def test_find_compatible_wheel_not_found(wheel: str, identifier: str): + assert find_compatible_wheel([PurePath(wheel)], identifier) is None