Skip to content
Draft
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 @@ -66,6 +66,26 @@ the following configuration to the ``pyproject.toml`` file::
For backwards compatibility, the setting of ``use_extension_helpers`` in
``setup.cfg`` will override any setting of it in ``pyproject.toml``.

If your package uses a `src layout
<https://setuptools.pypa.io/en/latest/userguide/package_discovery.html#src-layout>`_,
extension-helpers will look for extensions in the source directory declared
using the standard setuptools options, either in ``setup.cfg``::

[options]
package_dir =
= src

[options.packages.find]
where = src

or in ``pyproject.toml``::

[tool.setuptools.packages.find]
where = ["src"]

Note that automatic src layout discovery (without any explicit configuration)
is not supported, so one of the above options needs to be set explicitly.

Python limited API
------------------

Expand Down
24 changes: 22 additions & 2 deletions extension_helpers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@
from .version import version as __version__ # noqa: F401


def _get_srcdir_from_setup_cfg(cfg):
# The source root is the path mapped to the root package, e.g. "= src"
if cfg.has_option("options", "package_dir"):
for mapping in cfg.get("options", "package_dir").replace(",", "\n").splitlines():
package, sep, path = mapping.partition("=")
if sep and package.strip() == "":
return path.strip()
return "."


def _get_srcdir_from_pyproject(pyproject_cfg):
setuptools_cfg = pyproject_cfg.get("tool", {}).get("setuptools", {})
if "" in setuptools_cfg.get("package-dir", {}):
return setuptools_cfg["package-dir"][""]
packages = setuptools_cfg.get("packages", {})
if isinstance(packages, dict) and packages.get("find", {}).get("where"):
return packages["find"]["where"][0]
return "."


def _finalize_distribution_hook(distribution):
"""
Entry point for setuptools which allows extension-helpers to be enabled
Expand All @@ -29,7 +49,7 @@ def _finalize_distribution_hook(distribution):
if cfg.has_option("extension-helpers", "use_extension_helpers"):
found_config = True
if cfg.get("extension-helpers", "use_extension_helpers").lower() == "true":
distribution.ext_modules = get_extensions()
distribution.ext_modules = get_extensions(_get_srcdir_from_setup_cfg(cfg))

pyproject = Path(distribution.src_root or os.curdir, "pyproject.toml")
if pyproject.exists() and not found_config:
Expand All @@ -41,4 +61,4 @@ def _finalize_distribution_hook(distribution):
and "use_extension_helpers" in pyproject_cfg["tool"]["extension-helpers"]
and pyproject_cfg["tool"]["extension-helpers"]["use_extension_helpers"]
):
distribution.ext_modules = get_extensions()
distribution.ext_modules = get_extensions(_get_srcdir_from_pyproject(pyproject_cfg))
3 changes: 2 additions & 1 deletion extension_helpers/_setup_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ def get_extensions(srcdir="."):

extension.sources = sources

abi = get_limited_api_option(srcdir=srcdir)
# setup.cfg and pyproject.toml are in the current directory, not in srcdir
abi = get_limited_api_option(srcdir=".")
if abi:
version_info, version_hex = abi_to_versions(abi)

Expand Down
95 changes: 67 additions & 28 deletions extension_helpers/tests/test_setup_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import os
import subprocess
import sys
import sysconfig
import uuid
import zipfile
from textwrap import dedent

import pytest
Expand Down Expand Up @@ -36,22 +38,24 @@ def test_get_compiler():
def _extension_test_package(
tmp_path,
request=None,
src_layout=False,
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"
os.makedirs(test_pkg / "helpers_test_package")
(test_pkg / "helpers_test_package" / "__init__.py").touch()
src_dir = test_pkg / "src" if src_layout else test_pkg
os.makedirs(src_dir / "helpers_test_package")
(src_dir / "helpers_test_package" / "__init__.py").touch()

# TODO: It might be later worth making this particular test package into a
# reusable fixture for other build_ext tests

if extension_type in ("c", "both"):
# A minimal C extension for testing
(test_pkg / "helpers_test_package" / "unit01.c").write_text(dedent("""\
(src_dir / "helpers_test_package" / "unit01.c").write_text(dedent("""\
#include <Python.h>

static struct PyModuleDef moduledef = {
Expand All @@ -69,7 +73,7 @@ def _extension_test_package(

if extension_type in ("pyx", "both"):
# A minimal Cython extension for testing
(test_pkg / "helpers_test_package" / "unit02.pyx").write_text(dedent("""\
(src_dir / "helpers_test_package" / "unit02.pyx").write_text(dedent("""\
print("Hello cruel angel.")
"""))

Expand All @@ -82,21 +86,29 @@ def _extension_test_package(

include_dirs = ["numpy"] if include_numpy else []

pkg_dir = "'src', 'helpers_test_package'" if src_layout else "'helpers_test_package'"

extensions_list = [
f"Extension('helpers_test_package.{os.path.splitext(extension)[0]}', "
f"[join('helpers_test_package', '{extension}')], "
f"[join({pkg_dir}, '{extension}')], "
f"{include_dirs=})"
for extension in extensions
]

(test_pkg / "helpers_test_package" / "setup_package.py").write_text(dedent("""\
(src_dir / "helpers_test_package" / "setup_package.py").write_text(dedent("""\
from setuptools import Extension
from os.path import join
def get_extensions():
return [{}]
""".format(", ".join(extensions_list))))

if include_setup_py:
if src_layout:
packages_args = "packages=find_packages('src'), package_dir={'': 'src'}"
extensions_args = "get_extensions('src')"
else:
packages_args = "packages=find_packages()"
extensions_args = "get_extensions()"
(test_pkg / "setup.py").write_text(dedent(f"""\
import sys
from os.path import join
Expand All @@ -107,8 +119,8 @@ def get_extensions():
setup(
name='helpers_test_package',
version='0.1',
packages=find_packages(),
ext_modules=get_extensions()
{packages_args},
ext_modules={extensions_args}
)
"""))

Expand Down Expand Up @@ -413,26 +425,45 @@ def test():
# Tests to make sure that limited API support works correctly


@pytest.mark.parametrize("config", ("setup.cfg", "pyproject.toml"))
@pytest.mark.parametrize("config", ("setup.cfg", "setup.py", "pyproject.toml"))
@pytest.mark.parametrize("envvar", (False, True))
@pytest.mark.parametrize("limited_api", (None, "cp310"))
@pytest.mark.parametrize("extension_type", ("c", "pyx", "both"))
def test_limited_api(tmp_path, config, envvar, limited_api, extension_type):
@pytest.mark.parametrize("src_layout", (False, True))
def test_limited_api(tmp_path, config, envvar, limited_api, extension_type, src_layout):
pytest.importorskip("setuptools", minversion="65.4")

package = _extension_test_package(
tmp_path, extension_type=extension_type, include_numpy=True, include_setup_py=False
tmp_path,
extension_type=extension_type,
include_numpy=True,
include_setup_py=(config == "setup.py"),
src_layout=src_layout,
)

if config == "setup.cfg":
if config == "setup.py":

if limited_api and not envvar:
(package / "setup.cfg").write_text(f"[bdist_wheel]\npy_limited_api={limited_api}")
elif envvar:
# Make sure if we are using the environment variable that it takes
# precedence over this setting (this only works for setup.cfg)
(package / "setup.cfg").write_text("[bdist_wheel]\npy_limited_api=cp35")

elif config == "setup.cfg":

setup_cfg = dedent("""\
setup_cfg = dedent(f"""\
[metadata]
name = helpers_test_package
version = 0.1

[options]
packages = find:
package_dir =
= {'src' if src_layout else '.'}

[options.packages.find]
where = {'src' if src_layout else '.'}

[extension-helpers]
use_extension_helpers = true
Expand All @@ -447,21 +478,9 @@ def test_limited_api(tmp_path, config, envvar, limited_api, extension_type):

(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("""\
pyproject_toml = dedent(f"""\
[build-system]
requires = ["setuptools>=43.0.0",
"wheel"]
Expand All @@ -471,8 +490,9 @@ def test_limited_api(tmp_path, config, envvar, limited_api, extension_type):
name = "helpers_test_package"
version = "0.1"

[tool.setuptools.packages]
find = {namespaces = false}
[tool.setuptools.packages.find]
where = ["{'src' if src_layout else '.'}"]
namespaces = false

[tool.extension-helpers]
use_extension_helpers = true
Expand All @@ -483,6 +503,15 @@ def test_limited_api(tmp_path, config, envvar, limited_api, extension_type):

(package / "pyproject.toml").write_text(pyproject_toml)

if config != "pyproject.toml":
# A minimal pyproject.toml file is still required
(package / "pyproject.toml").write_text(dedent("""
[build-system]
requires = ["setuptools>=43.0.0",
"wheel"]
build-backend = 'setuptools.build_meta'
"""))

env = os.environ.copy()

if envvar:
Expand All @@ -501,6 +530,16 @@ def test_limited_api(tmp_path, config, envvar, limited_api, extension_type):
assert len(wheels) == 1
assert ("abi3" in wheels[0]) == (limited_api is not None)

# The wheel tag above only reflects the bdist_wheel option, so also check
# that the extensions themselves were built for the limited API, in which
# case they don't use the default (version-specific) extension suffix
ext_suffix = sysconfig.get_config_var("EXT_SUFFIX")
with zipfile.ZipFile(package / "dist" / wheels[0]) as wheel:
ext_files = [f for f in wheel.namelist() if f.endswith((".so", ".pyd"))]
assert ext_files
for filename in ext_files:
assert filename.endswith(ext_suffix) == (limited_api is None)


def test_limited_api_invalid_abi(tmp_path, capsys):

Expand Down
Loading