diff --git a/docs/using.rst b/docs/using.rst index 03c63b2..ab0f341 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -65,3 +65,23 @@ the following configuration to the ``pyproject.toml`` file:: .. note:: For backwards compatibility, the setting of ``use_extension_helpers`` in ``setup.cfg`` will override any setting of it in ``pyproject.toml``. + +Python limited API +------------------ + +Your package may opt in to the :pep:`384` Python Limited API so that a single +binary wheel works with many different versions of Python on the same platform. +For this to work, any C extensions you write needs to make use only of +`certain C functions `_. + +To opt in to the Python Limited API, add the following standard setuptools +option to your project's ``setup.cfg`` file:: + + [bdist_wheel] + py_limited_api = cp311 + +Here, ``311`` denotes API compatibility with Python >= 3.11. Replace with the +lowest major and minor version number that you wish to support. + +The ``get_extensions()`` functions will automatically detect this option and +add the necessary compiler flags to build your extension modules. diff --git a/extension_helpers/_setup_helpers.py b/extension_helpers/_setup_helpers.py index b57859f..3d27971 100644 --- a/extension_helpers/_setup_helpers.py +++ b/extension_helpers/_setup_helpers.py @@ -14,7 +14,12 @@ from setuptools import Extension, find_packages from setuptools.command.build_ext import new_compiler -from ._utils import import_file, walk_skip_hidden +from ._utils import ( + abi_to_versions, + get_limited_api_option, + import_file, + walk_skip_hidden, +) __all__ = ["get_compiler", "get_extensions", "pkg_config"] @@ -135,6 +140,21 @@ def get_extensions(srcdir="."): extension.sources = sources + abi = get_limited_api_option(srcdir=srcdir) + if abi: + version_info, version_hex = abi_to_versions(abi) + + if version_info is None: + raise ValueError(f"Unrecognized abi version for limited API: {abi}") + + log.info( + f"Targeting PEP 384 limited API supporting Python >= {version_info[0], version_info[1]}" + ) + + for ext in ext_modules: + ext.py_limited_api = True + ext.define_macros.append(("Py_LIMITED_API", version_hex)) + return ext_modules diff --git a/extension_helpers/_utils.py b/extension_helpers/_utils.py index 42d1632..edb3120 100644 --- a/extension_helpers/_utils.py +++ b/extension_helpers/_utils.py @@ -1,12 +1,20 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst import os +import re import sys +from configparser import ConfigParser from importlib import machinery as import_machinery from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path -__all__ = ["write_if_different", "import_file"] +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +__all__ = ["write_if_different", "import_file", "get_limited_api_option", "abi_to_versions"] if sys.platform == "win32": @@ -138,3 +146,53 @@ def import_file(filename, name=None): loader.exec_module(mod) return mod + + +def get_limited_api_option(srcdir): + """ + Checks setup.cfg and pyproject.toml files in the current directory + for the py_limited_api setting + """ + + srcdir = Path(srcdir) + + setup_cfg = srcdir / "setup.cfg" + + if setup_cfg.exists(): + cfg = ConfigParser() + cfg.read(setup_cfg) + if cfg.has_option("bdist_wheel", "py_limited_api"): + return cfg.get("bdist_wheel", "py_limited_api") + + pyproject = srcdir / "pyproject.toml" + if pyproject.exists(): + with pyproject.open("rb") as f: + pyproject_cfg = tomllib.load(f) + if ( + "tool" in pyproject_cfg + and "distutils" in pyproject_cfg["tool"] + and "bdist_wheel" in pyproject_cfg["tool"]["distutils"] + and "py-limited-api" in pyproject_cfg["tool"]["distutils"]["bdist_wheel"] + ): + return pyproject_cfg["tool"]["distutils"]["bdist_wheel"]["py-limited-api"] + + +def _abi_to_version_info(abi): + match = re.fullmatch(r"^cp(\d)(\d+)$", abi) + if match is None: + return None + else: + return int(match[1]), int(match[2]) + + +def _version_info_to_version_hex(major=0, minor=0): + """Returns a PY_VERSION_HEX for {major}.{minor).0""" + return f"0x{major:02X}{minor:02X}0000" + + +def abi_to_versions(abi): + version_info = _abi_to_version_info(abi) + if version_info is None: + return None, None + else: + return version_info, _version_info_to_version_hex(*version_info) diff --git a/extension_helpers/tests/test_setup_helpers.py b/extension_helpers/tests/test_setup_helpers.py index 66dda16..2802943 100644 --- a/extension_helpers/tests/test_setup_helpers.py +++ b/extension_helpers/tests/test_setup_helpers.py @@ -35,7 +35,13 @@ def test_get_compiler(): assert get_compiler() in POSSIBLE_COMPILERS -def _extension_test_package(tmp_path, request, extension_type="c", include_numpy=False): +def _extension_test_package( + tmp_path, + request=None, + extension_type="c", + include_numpy=False, + include_setup_py=True, +): """Creates a simple test package with an extension module.""" test_pkg = tmp_path / "test_pkg" @@ -106,24 +112,25 @@ def get_extensions(): ) ) - (test_pkg / "setup.py").write_text( - dedent( - f"""\ - import sys - from os.path import join - from setuptools import setup, find_packages - sys.path.insert(0, r'{extension_helpers_PATH}') - from extension_helpers import get_extensions - - setup( - name='helpers_test_package', - version='0.1', - packages=find_packages(), - ext_modules=get_extensions() - ) - """ + if include_setup_py: + (test_pkg / "setup.py").write_text( + dedent( + f"""\ + import sys + from os.path import join + from setuptools import setup, find_packages + sys.path.insert(0, r'{extension_helpers_PATH}') + from extension_helpers import get_extensions + + setup( + name='helpers_test_package', + version='0.1', + packages=find_packages(), + ext_modules=get_extensions() + ) + """ + ) ) - ) if "" in sys.path: sys.path.remove("") @@ -133,7 +140,8 @@ def get_extensions(): def finalize(): cleanup_import("helpers_test_package") - request.addfinalizer(finalize) + if request: + request.addfinalizer(finalize) return test_pkg @@ -455,3 +463,133 @@ def test(): pass else: raise AssertionError(package_name + ".compiler_version should not exist") + + +# Tests to make sure that limited API support works correctly + + +@pytest.mark.parametrize("config", ("setup.cfg", "pyproject.toml")) +@pytest.mark.parametrize("limited_api", (None, "cp310")) +@pytest.mark.parametrize("extension_type", ("c", "pyx", "both")) +def test_limited_api(tmp_path, config, limited_api, extension_type): + + package = _extension_test_package( + tmp_path, extension_type=extension_type, include_numpy=True, include_setup_py=False + ) + + if config == "setup.cfg": + + setup_cfg = dedent( + """\ + [metadata] + name = helpers_test_package + version = 0.1 + + [options] + packages = find: + + [extension-helpers] + use_extension_helpers = true + """ + ) + + if limited_api: + setup_cfg += f"\n[bdist_wheel]\npy_limited_api={limited_api}" + + (package / "setup.cfg").write_text(setup_cfg) + + # Still require a minimal pyproject.toml file if no setup.py file + + (package / "pyproject.toml").write_text( + dedent( + """ + [build-system] + requires = ["setuptools>=43.0.0", + "wheel"] + build-backend = 'setuptools.build_meta' + + [tool.extension-helpers] + use_extension_helpers = true + """ + ) + ) + + elif config == "pyproject.toml": + + pyproject_toml = dedent( + """\ + [build-system] + requires = ["setuptools>=43.0.0", + "wheel"] + build-backend = 'setuptools.build_meta' + + [project] + name = "hehlpers_test_package" + version = "0.1" + + [tool.setuptools.packages] + find = {namespaces = false} + + [tool.extension-helpers] + use_extension_helpers = true + """ + ) + + if limited_api: + pyproject_toml += f'\n[tool.distutils.bdist_wheel]\npy-limited-api = "{limited_api}"' + + (package / "pyproject.toml").write_text(pyproject_toml) + + with chdir(package): + subprocess.run([sys.executable, "-m", "build", "--wheel", "--no-isolation"], check=True) + + wheels = os.listdir(package / "dist") + + assert len(wheels) == 1 + assert ("abi3" in wheels[0]) == (limited_api is not None) + + +def test_limited_api_invalid_abi(tmp_path, capsys): + + package = _extension_test_package( + tmp_path, extension_type="c", include_numpy=True, include_setup_py=False + ) + + (package / "setup.cfg").write_text( + dedent( + """\ + [metadata] + name = helpers_test_package + version = 0.1 + + [options] + packages = find: + + [extension-helpers] + use_extension_helpers = true + + [bdist_wheel] + py_limited_api=invalid + """ + ) + ) + + (package / "pyproject.toml").write_text( + dedent( + """ + [build-system] + requires = ["setuptools>=43.0.0", + "wheel"] + build-backend = 'setuptools.build_meta' + """ + ) + ) + + with chdir(package): + result = subprocess.run( + [sys.executable, "-m", "build", "--wheel", "--no-isolation"], stderr=subprocess.PIPE + ) + + assert result.stderr.strip().endswith( + b"ValueError: Unrecognized abi version for limited API: invalid" + ) diff --git a/extension_helpers/tests/test_utils.py b/extension_helpers/tests/test_utils.py index 6d9aa2e..285c78d 100644 --- a/extension_helpers/tests/test_utils.py +++ b/extension_helpers/tests/test_utils.py @@ -3,7 +3,12 @@ import pytest -from .._utils import import_file, write_if_different +from .._utils import ( + abi_to_versions, + get_limited_api_option, + import_file, + write_if_different, +) @pytest.mark.parametrize("path_type", ("str", "path")) @@ -32,3 +37,61 @@ def test_write_if_different(tmp_path, path_type): write_if_different(filepath, b"abcd") time3 = os.path.getmtime(filepath) assert time3 > time1 + + +class TestGetLimitedAPIOption: + + def test_nofiles(self, tmp_path): + assert get_limited_api_option(tmp_path) is None + + def test_empty_setup_cfg(self, tmp_path): + (tmp_path / "setup.cfg").write_text("") + assert get_limited_api_option(tmp_path) is None + + def test_empty_pyproject_toml(self, tmp_path): + (tmp_path / "pyproject.toml").write_text("") + assert get_limited_api_option(tmp_path) is None + + def test_setup_cfg(self, tmp_path): + + (tmp_path / "setup.cfg").write_text("[bdist_wheel]\npy_limited_api=cp311") + assert get_limited_api_option(tmp_path) == "cp311" + + # Make sure things still work even if an empty pyproject.toml file is present + + (tmp_path / "pyproject.toml").write_text("") + assert get_limited_api_option(tmp_path) == "cp311" + + # And if the pyproject.toml has the right section but not the right option + + (tmp_path / "setup.cfg.toml").write_text("[tool.distutils.bdist_wheel]\nspam=1\n") + assert get_limited_api_option(tmp_path) == "cp311" + + def test_pyproject(self, tmp_path): + + (tmp_path / "pyproject.toml").write_text( + '[tool.distutils.bdist_wheel]\npy-limited-api="cp312"\n' + ) + assert get_limited_api_option(tmp_path) == "cp312" + + # Make sure things still work even if an empty setup.cfg file is present + + (tmp_path / "setup.cfg.toml").write_text("\n") + assert get_limited_api_option(tmp_path) == "cp312" + + # And if the setup.cfg has the right section but not the right option + + (tmp_path / "setup.cfg.toml").write_text("[bdist_wheel]\nspam=1\n") + assert get_limited_api_option(tmp_path) == "cp312" + + +def test_abi_to_versions_invalid(): + assert abi_to_versions("spam") == (None, None) + + +def test_abi_to_versions_valid(): + assert abi_to_versions("cp39") == ((3, 9), "0x03090000") + assert abi_to_versions("cp310") == ((3, 10), "0x030A0000") + assert abi_to_versions("cp311") == ((3, 11), "0x030B0000") + assert abi_to_versions("cp312") == ((3, 12), "0x030C0000") + assert abi_to_versions("cp313") == ((3, 13), "0x030D0000") diff --git a/pyproject.toml b/pyproject.toml index 1954aaf..b410aa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ test = [ "pytest", "pytest-cov", "cython", + "build" ] docs = [ "sphinx",