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

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>`_.
Comment thread
Cadair marked this conversation as resolved.
Outdated

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.

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 @@

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:
log.warn("Unrecognized abi version for limited API: {abi}")

Check warning on line 148 in extension_helpers/_setup_helpers.py

View check run for this annotation

Codecov / codecov/patch

extension_helpers/_setup_helpers.py#L148

Added line #L148 was not covered by tests
Comment thread
astrofrog marked this conversation as resolved.
Outdated

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 @@
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

Check warning on line 183 in extension_helpers/_utils.py

View check run for this annotation

Codecov / codecov/patch

extension_helpers/_utils.py#L183

Added line #L183 was not covered by tests
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

Check warning on line 196 in extension_helpers/_utils.py

View check run for this annotation

Codecov / codecov/patch

extension_helpers/_utils.py#L196

Added line #L196 was not covered by tests
else:
return version_info, _version_info_to_version_hex(*version_info)
130 changes: 111 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,87 @@ 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)
65 changes: 64 additions & 1 deletion extension_helpers/tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ test = [
"pytest",
"pytest-cov",
"cython",
"build"
]
docs = [
"sphinx",
Expand Down
Loading