Skip to content

Automatically set compiler flags to target PEP 384 Python limited API #26

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
------------------

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a similar vein to my other comment, it would be nice to explain when using this is appropriate and when it isn't as I expect most people don't even know this exists (I didn't) and why they would want to use it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're right! It's not a well-known feature, although it seems to be well supported by pip. Part of the aim of this PR is to raise awareness by making it easy for Astropy affiliated packages to opt in.

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

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