Skip to content
Merged
28 changes: 28 additions & 0 deletions docs/using.rst
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,31 @@ 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
------------------

Comment thread
Cadair marked this conversation as resolved.
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 <https://docs.python.org/3/c-api/stable.html#limited-api-list>`__.

To opt in to the Python Limited API, add the following standard setuptools
option to your project's ``setup.cfg`` file::
Comment thread
Cadair marked this conversation as resolved.

[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.

You can also set this option in ``pyproject.toml``, using::

[tool.distutils.bdist_wheel]
py-limited-api = "cp312"

although note that this option is not formally documented/supported by the Python
packaging infrastructure and may change in future.

The ``get_extensions()`` functions will automatically detect this option and
add the necessary compiler flags to build your extension modules.
22 changes: 21 additions & 1 deletion extension_helpers/_setup_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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


Expand Down
60 changes: 59 additions & 1 deletion extension_helpers/_utils.py
Original file line number Diff line number Diff line change
@@ -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":
Expand Down Expand Up @@ -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)
176 changes: 157 additions & 19 deletions extension_helpers/tests/test_setup_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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("")
Expand All @@ -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

Expand Down Expand Up @@ -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"
)
Loading
Loading