Skip to content

Commit 1986199

Browse files
authored
Merge pull request #26 from lpsinger/py-limited-api
Automatically set compiler flags to target PEP 384 Python limited API
2 parents 8859f4f + 132e5fb commit 1986199

File tree

6 files changed

+330
-22
lines changed

6 files changed

+330
-22
lines changed

docs/using.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,31 @@ the following configuration to the ``pyproject.toml`` file::
6565
.. note::
6666
For backwards compatibility, the setting of ``use_extension_helpers`` in
6767
``setup.cfg`` will override any setting of it in ``pyproject.toml``.
68+
69+
Python limited API
70+
------------------
71+
72+
Your package may opt in to the :pep:`384` Python Limited API so that a single
73+
binary wheel works with many different versions of Python on the same platform.
74+
For this to work, any C extensions you write needs to make use only of
75+
`certain C functions <https://docs.python.org/3/c-api/stable.html#limited-api-list>`__.
76+
77+
To opt in to the Python Limited API, add the following standard setuptools
78+
option to your project's ``setup.cfg`` file::
79+
80+
[bdist_wheel]
81+
py_limited_api = cp311
82+
83+
Here, ``311`` denotes API compatibility with Python >= 3.11. Replace with the
84+
lowest major and minor version number that you wish to support.
85+
86+
You can also set this option in ``pyproject.toml``, using::
87+
88+
[tool.distutils.bdist_wheel]
89+
py-limited-api = "cp312"
90+
91+
although note that this option is not formally documented/supported by the Python
92+
packaging infrastructure and may change in future.
93+
94+
The ``get_extensions()`` functions will automatically detect this option and
95+
add the necessary compiler flags to build your extension modules.

extension_helpers/_setup_helpers.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from setuptools import Extension, find_packages
1515
from setuptools.command.build_ext import new_compiler
1616

17-
from ._utils import import_file, walk_skip_hidden
17+
from ._utils import (
18+
abi_to_versions,
19+
get_limited_api_option,
20+
import_file,
21+
walk_skip_hidden,
22+
)
1823

1924
__all__ = ["get_compiler", "get_extensions", "pkg_config"]
2025

@@ -135,6 +140,21 @@ def get_extensions(srcdir="."):
135140

136141
extension.sources = sources
137142

143+
abi = get_limited_api_option(srcdir=srcdir)
144+
if abi:
145+
version_info, version_hex = abi_to_versions(abi)
146+
147+
if version_info is None:
148+
raise ValueError(f"Unrecognized abi version for limited API: {abi}")
149+
150+
log.info(
151+
f"Targeting PEP 384 limited API supporting Python >= {version_info[0], version_info[1]}"
152+
)
153+
154+
for ext in ext_modules:
155+
ext.py_limited_api = True
156+
ext.define_macros.append(("Py_LIMITED_API", version_hex))
157+
138158
return ext_modules
139159

140160

extension_helpers/_utils.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
# Licensed under a 3-clause BSD style license - see LICENSE.rst
22

33
import os
4+
import re
45
import sys
6+
from configparser import ConfigParser
57
from importlib import machinery as import_machinery
68
from importlib.util import module_from_spec, spec_from_file_location
79
from pathlib import Path
810

9-
__all__ = ["write_if_different", "import_file"]
11+
if sys.version_info >= (3, 11):
12+
import tomllib
13+
else:
14+
import tomli as tomllib
15+
16+
17+
__all__ = ["write_if_different", "import_file", "get_limited_api_option", "abi_to_versions"]
1018

1119

1220
if sys.platform == "win32":
@@ -138,3 +146,53 @@ def import_file(filename, name=None):
138146
loader.exec_module(mod)
139147

140148
return mod
149+
150+
151+
def get_limited_api_option(srcdir):
152+
"""
153+
Checks setup.cfg and pyproject.toml files in the current directory
154+
for the py_limited_api setting
155+
"""
156+
157+
srcdir = Path(srcdir)
158+
159+
setup_cfg = srcdir / "setup.cfg"
160+
161+
if setup_cfg.exists():
162+
cfg = ConfigParser()
163+
cfg.read(setup_cfg)
164+
if cfg.has_option("bdist_wheel", "py_limited_api"):
165+
return cfg.get("bdist_wheel", "py_limited_api")
166+
167+
pyproject = srcdir / "pyproject.toml"
168+
if pyproject.exists():
169+
with pyproject.open("rb") as f:
170+
pyproject_cfg = tomllib.load(f)
171+
if (
172+
"tool" in pyproject_cfg
173+
and "distutils" in pyproject_cfg["tool"]
174+
and "bdist_wheel" in pyproject_cfg["tool"]["distutils"]
175+
and "py-limited-api" in pyproject_cfg["tool"]["distutils"]["bdist_wheel"]
176+
):
177+
return pyproject_cfg["tool"]["distutils"]["bdist_wheel"]["py-limited-api"]
178+
179+
180+
def _abi_to_version_info(abi):
181+
match = re.fullmatch(r"^cp(\d)(\d+)$", abi)
182+
if match is None:
183+
return None
184+
else:
185+
return int(match[1]), int(match[2])
186+
187+
188+
def _version_info_to_version_hex(major=0, minor=0):
189+
"""Returns a PY_VERSION_HEX for {major}.{minor).0"""
190+
return f"0x{major:02X}{minor:02X}0000"
191+
192+
193+
def abi_to_versions(abi):
194+
version_info = _abi_to_version_info(abi)
195+
if version_info is None:
196+
return None, None
197+
else:
198+
return version_info, _version_info_to_version_hex(*version_info)

extension_helpers/tests/test_setup_helpers.py

Lines changed: 157 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ def test_get_compiler():
3535
assert get_compiler() in POSSIBLE_COMPILERS
3636

3737

38-
def _extension_test_package(tmp_path, request, extension_type="c", include_numpy=False):
38+
def _extension_test_package(
39+
tmp_path,
40+
request=None,
41+
extension_type="c",
42+
include_numpy=False,
43+
include_setup_py=True,
44+
):
3945
"""Creates a simple test package with an extension module."""
4046

4147
test_pkg = tmp_path / "test_pkg"
@@ -106,24 +112,25 @@ def get_extensions():
106112
)
107113
)
108114

109-
(test_pkg / "setup.py").write_text(
110-
dedent(
111-
f"""\
112-
import sys
113-
from os.path import join
114-
from setuptools import setup, find_packages
115-
sys.path.insert(0, r'{extension_helpers_PATH}')
116-
from extension_helpers import get_extensions
117-
118-
setup(
119-
name='helpers_test_package',
120-
version='0.1',
121-
packages=find_packages(),
122-
ext_modules=get_extensions()
123-
)
124-
"""
115+
if include_setup_py:
116+
(test_pkg / "setup.py").write_text(
117+
dedent(
118+
f"""\
119+
import sys
120+
from os.path import join
121+
from setuptools import setup, find_packages
122+
sys.path.insert(0, r'{extension_helpers_PATH}')
123+
from extension_helpers import get_extensions
124+
125+
setup(
126+
name='helpers_test_package',
127+
version='0.1',
128+
packages=find_packages(),
129+
ext_modules=get_extensions()
130+
)
131+
"""
132+
)
125133
)
126-
)
127134

128135
if "" in sys.path:
129136
sys.path.remove("")
@@ -133,7 +140,8 @@ def get_extensions():
133140
def finalize():
134141
cleanup_import("helpers_test_package")
135142

136-
request.addfinalizer(finalize)
143+
if request:
144+
request.addfinalizer(finalize)
137145

138146
return test_pkg
139147

@@ -455,3 +463,133 @@ def test():
455463
pass
456464
else:
457465
raise AssertionError(package_name + ".compiler_version should not exist")
466+
467+
468+
# Tests to make sure that limited API support works correctly
469+
470+
471+
@pytest.mark.parametrize("config", ("setup.cfg", "pyproject.toml"))
472+
@pytest.mark.parametrize("limited_api", (None, "cp310"))
473+
@pytest.mark.parametrize("extension_type", ("c", "pyx", "both"))
474+
def test_limited_api(tmp_path, config, limited_api, extension_type):
475+
476+
package = _extension_test_package(
477+
tmp_path, extension_type=extension_type, include_numpy=True, include_setup_py=False
478+
)
479+
480+
if config == "setup.cfg":
481+
482+
setup_cfg = dedent(
483+
"""\
484+
[metadata]
485+
name = helpers_test_package
486+
version = 0.1
487+
488+
[options]
489+
packages = find:
490+
491+
[extension-helpers]
492+
use_extension_helpers = true
493+
"""
494+
)
495+
496+
if limited_api:
497+
setup_cfg += f"\n[bdist_wheel]\npy_limited_api={limited_api}"
498+
499+
(package / "setup.cfg").write_text(setup_cfg)
500+
501+
# Still require a minimal pyproject.toml file if no setup.py file
502+
503+
(package / "pyproject.toml").write_text(
504+
dedent(
505+
"""
506+
[build-system]
507+
requires = ["setuptools>=43.0.0",
508+
"wheel"]
509+
build-backend = 'setuptools.build_meta'
510+
511+
[tool.extension-helpers]
512+
use_extension_helpers = true
513+
"""
514+
)
515+
)
516+
517+
elif config == "pyproject.toml":
518+
519+
pyproject_toml = dedent(
520+
"""\
521+
[build-system]
522+
requires = ["setuptools>=43.0.0",
523+
"wheel"]
524+
build-backend = 'setuptools.build_meta'
525+
526+
[project]
527+
name = "hehlpers_test_package"
528+
version = "0.1"
529+
530+
[tool.setuptools.packages]
531+
find = {namespaces = false}
532+
533+
[tool.extension-helpers]
534+
use_extension_helpers = true
535+
"""
536+
)
537+
538+
if limited_api:
539+
pyproject_toml += f'\n[tool.distutils.bdist_wheel]\npy-limited-api = "{limited_api}"'
540+
541+
(package / "pyproject.toml").write_text(pyproject_toml)
542+
543+
with chdir(package):
544+
subprocess.run([sys.executable, "-m", "build", "--wheel", "--no-isolation"], check=True)
545+
546+
wheels = os.listdir(package / "dist")
547+
548+
assert len(wheels) == 1
549+
assert ("abi3" in wheels[0]) == (limited_api is not None)
550+
551+
552+
def test_limited_api_invalid_abi(tmp_path, capsys):
553+
554+
package = _extension_test_package(
555+
tmp_path, extension_type="c", include_numpy=True, include_setup_py=False
556+
)
557+
558+
(package / "setup.cfg").write_text(
559+
dedent(
560+
"""\
561+
[metadata]
562+
name = helpers_test_package
563+
version = 0.1
564+
565+
[options]
566+
packages = find:
567+
568+
[extension-helpers]
569+
use_extension_helpers = true
570+
571+
[bdist_wheel]
572+
py_limited_api=invalid
573+
"""
574+
)
575+
)
576+
577+
(package / "pyproject.toml").write_text(
578+
dedent(
579+
"""
580+
[build-system]
581+
requires = ["setuptools>=43.0.0",
582+
"wheel"]
583+
build-backend = 'setuptools.build_meta'
584+
"""
585+
)
586+
)
587+
588+
with chdir(package):
589+
result = subprocess.run(
590+
[sys.executable, "-m", "build", "--wheel", "--no-isolation"], stderr=subprocess.PIPE
591+
)
592+
593+
assert result.stderr.strip().endswith(
594+
b"ValueError: Unrecognized abi version for limited API: invalid"
595+
)

0 commit comments

Comments
 (0)