"""PEP 517 build backend for IVPM-managed projects.
Wraps ``setuptools.build_meta`` and injects IVPM configuration parsed from
``[tool.ivpm-build]`` in ``pyproject.toml`` before each hook is invoked.
"""
import contextlib
import importlib
import os
import platform
import shutil
import sys
import setuptools.build_meta as _st
from .config import load_config
from .setup import ivpm_data as _idata
from .setup.ivpm_data import expand_libvars
def _apply_ivpm_config(config=None):
if config is None:
config = load_config()
if config.extra_data:
_idata._ivpm_extra_data = {
spec.pkg: [(spec.src, spec.dst)] for spec in config.extra_data
}
if config.ext_name_map:
_idata._ivpm_ext_name_m = {
e.module: e.name for e in config.ext_name_map
}
# ---------------------------------------------------------------------------
# extra-data staging
#
# In the pure-pyproject (backend) path there is no setup.py, so the InstallLib
# command that copies ``extra-data`` artifacts into the wheel is never wired in.
# Instead we copy each ``extra-data`` entry into the package's *source* directory
# before delegating to setuptools, so the files are picked up as ordinary package
# data, then remove them afterwards to keep the source tree clean.
# ---------------------------------------------------------------------------
def _load_setuptools_package_dir():
"""Return the ``[tool.setuptools] package-dir`` mapping from pyproject.toml.
Returns an empty dict when the file or section is absent.
"""
path = "pyproject.toml"
if not os.path.isfile(path):
return {}
try:
import tomllib # Python >= 3.11
except ImportError:
import tomli as tomllib # type: ignore[no-redef]
with open(path, "rb") as fh:
data = tomllib.load(fh)
return data.get("tool", {}).get("setuptools", {}).get("package-dir", {}) or {}
def _pkg_src_dir(pkg, package_dir):
"""Resolve a package name to its source directory using setuptools' rules."""
parts = pkg.split(".")
if pkg in package_dir:
return package_dir[pkg].replace("/", os.sep)
if "" in package_dir:
return os.path.join(package_dir[""], *parts)
return os.path.join(*parts)
def _resolve_extra_data_src(src):
"""Expand template vars in *src* and return an existing absolute path or None.
Tries the default ``{libdir}`` resolution first, then explicit ``lib``/
``lib64`` so a single spec works regardless of where CMake placed the lib.
"""
for cand in (
expand_libvars(src),
expand_libvars(src, libdir="lib"),
expand_libvars(src, libdir="lib64"),
):
p = cand if os.path.isabs(cand) else os.path.join(os.getcwd(), cand)
if os.path.exists(p):
return p
return None
def _stage_extra_data(config):
"""Copy ``extra-data`` artifacts into package source dirs for the wheel build.
Returns the list of paths created, so the caller can remove them afterwards.
"""
staged = []
if not config.extra_data:
return staged
package_dir = _load_setuptools_package_dir()
for spec in config.extra_data:
src = _resolve_extra_data_src(spec.src)
if src is None:
print("ivpm-build: warning: extra-data src not found: %s" % spec.src)
continue
pkg_dir = _pkg_src_dir(spec.pkg, package_dir)
if os.path.isfile(src):
dst_dir = os.path.join(pkg_dir, spec.dst)
os.makedirs(dst_dir, exist_ok=True)
dst = os.path.join(dst_dir, os.path.basename(src))
shutil.copyfile(src, dst)
staged.append(dst)
print("ivpm-build: staged %s -> %s" % (src, dst))
# On Windows ship the import library alongside the DLL.
if "{dllext}" in spec.src and platform.system() == "Windows":
link_lib = src.replace(".dll", ".lib")
if os.path.isfile(link_lib):
link_dst = os.path.join(dst_dir, os.path.basename(link_lib))
shutil.copyfile(link_lib, link_dst)
staged.append(link_dst)
elif os.path.isdir(src):
dst = os.path.join(pkg_dir, spec.dst, os.path.basename(src))
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
staged.append(dst)
print("ivpm-build: staged %s/ -> %s/" % (src, dst))
return staged
def _unstage_extra_data(staged):
for p in staged:
try:
if os.path.isdir(p):
shutil.rmtree(p, ignore_errors=True)
elif os.path.isfile(p):
os.remove(p)
except OSError as e:
print("ivpm-build: warning: failed to clean staged %s (%s)" % (p, e))
def _run_cmake():
from .cmake.cmake_builder import CmakeBuilder
CmakeBuilder(os.getcwd()).run()
# ---------------------------------------------------------------------------
# Platform wheel tagging
#
# A project that ships a CMake-built native library *loaded via ctypes* has no
# Python C-extension (no ``ext_modules``). setuptools therefore considers it a
# pure-Python project and emits a ``py3-none-any`` wheel -- even though the
# wheel now contains a platform-specific ``.so``/``.dll``/``.dylib``. Such a
# wheel is wrong (it would install on the wrong OS/arch) and tools like
# ``cibuildwheel`` reject it ("Build failed because a pure Python wheel was
# generated").
#
# Because the library is loaded via ctypes it is *not* tied to a specific
# CPython ABI, so the correct tag is ``py3-none-<platform>`` (e.g.
# ``py3-none-manylinux2014_x86_64`` after auditwheel) -- platform-specific but
# interpreter/ABI agnostic -- NOT ``cp311-cp311-...`` (which would force a
# redundant build per Python version).
#
# There is no setup.py in the pure-pyproject path, so we cannot register a
# ``cmdclass``. Instead we patch the ``bdist_wheel`` command class in-process
# for the duration of the delegated build. setuptools.build_meta runs setup()
# in this same process, so the patch takes effect; we restore it afterwards.
# ---------------------------------------------------------------------------
# Candidate locations of the bdist_wheel command class, newest first.
# setuptools >= 70.1 vendors it at setuptools.command.bdist_wheel
# older setuptools delegates to the standalone ``wheel`` package
_BDIST_WHEEL_MODULES = (
"setuptools.command.bdist_wheel",
"wheel.bdist_wheel",
)
@contextlib.contextmanager
def _platform_wheel_tag():
"""Force an impure, ABI-agnostic ``py3-none-<platform>`` wheel tag.
Two things have to change versus a default setuptools build of an
extension-less project:
1. The distribution must be treated as carrying platform binaries so the
package is installed into *platlib* -- otherwise the wheel root (platlib)
and the installed files (purelib) disagree and setuptools relocates the
package under ``<name>.data/purelib/``. We do this by making
``Distribution.has_ext_modules()`` report ``True`` for the build, which
also flips ``bdist_wheel.root_is_pure`` to ``False`` automatically.
2. The wheel tag must be ``py3-none-<plat>`` (interpreter/ABI agnostic),
because the native library is loaded via ctypes and is not bound to a
CPython ABI. A project that genuinely ships a C-extension keeps its
original ``cpXX-<abi>-<plat>`` tag.
The genuine ``has_ext_modules`` answer is recorded per-distribution as
``_ivpm_real_ext`` so the tag logic can tell the two cases apart.
"""
from setuptools.dist import Distribution
patched = []
# (1) platlib placement -- also makes root_is_pure False for free.
orig_has_ext = Distribution.has_ext_modules
def has_ext_modules(self, _orig=orig_has_ext):
if not hasattr(self, "_ivpm_real_ext"):
self._ivpm_real_ext = bool(_orig(self))
return True
Distribution.has_ext_modules = has_ext_modules
# (2) interpreter/ABI-agnostic tag, unless there is a real C-extension.
for mod_name in _BDIST_WHEEL_MODULES:
try:
mod = importlib.import_module(mod_name)
except ImportError:
continue
cls = getattr(mod, "bdist_wheel", None)
if cls is None or getattr(cls, "_ivpm_tag_patched", False):
continue
orig_get_tag = cls.get_tag
def get_tag(self, _orig=orig_get_tag):
impl, abi, plat = _orig(self)
if getattr(self.distribution, "_ivpm_real_ext", False):
return impl, abi, plat
return "py3", "none", plat
cls.get_tag = get_tag
cls._ivpm_tag_patched = True
patched.append((cls, orig_get_tag))
# The first importable module in priority order is the canonical
# bdist_wheel command; patching further (legacy) copies is unnecessary
# and would emit deprecation warnings.
break
try:
yield
finally:
Distribution.has_ext_modules = orig_has_ext
for cls, orig_get_tag in patched:
cls.get_tag = orig_get_tag
del cls._ivpm_tag_patched
# ---------------------------------------------------------------------------
# PEP 517 hooks
# ---------------------------------------------------------------------------
[docs]
def get_requires_for_build_wheel(config_settings=None):
config = load_config()
base = list(_st.get_requires_for_build_wheel(config_settings))
if config.cmake and sys.platform != "win32":
base.append("ninja")
return base
[docs]
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
config = load_config()
_apply_ivpm_config(config)
if config.cmake:
_run_cmake()
staged = _stage_extra_data(config)
try:
# When CMake produced native artifacts, force a platform-specific wheel
# tag so the wheel is not mistagged as pure-Python (py3-none-any).
if config.cmake:
with _platform_wheel_tag():
return _st.build_wheel(
wheel_directory, config_settings, metadata_directory)
return _st.build_wheel(wheel_directory, config_settings, metadata_directory)
finally:
_unstage_extra_data(staged)
[docs]
def build_sdist(sdist_directory, config_settings=None):
return _st.build_sdist(sdist_directory, config_settings)
[docs]
def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
config = load_config()
_apply_ivpm_config(config)
if config.cmake:
_run_cmake()
return _st.build_editable(wheel_directory, config_settings, metadata_directory)
[docs]
def get_requires_for_build_editable(config_settings=None):
config = load_config()
base = list(_st.get_requires_for_build_editable(config_settings))
if config.cmake and sys.platform != "win32":
base.append("ninja")
return base