From 094cc46306145ac08a9f8d8849bed7931d6a0087 Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 22:14:42 +0900 Subject: [PATCH 1/7] Add C++ tangency backend and backend dispatcher --- src/ellphi/_backend.py | 83 ++++++ src/ellphi/_tangency_cpp.py | 207 +++++++++++++++ src/ellphi/_tangency_cpp_impl.cpp | 427 ++++++++++++++++++++++++++++++ src/ellphi/solver.py | 100 ++++++- tests/test_cpp_backend.py | 71 +++++ 5 files changed, 880 insertions(+), 8 deletions(-) create mode 100644 src/ellphi/_backend.py create mode 100644 src/ellphi/_tangency_cpp.py create mode 100644 src/ellphi/_tangency_cpp_impl.cpp create mode 100644 tests/test_cpp_backend.py diff --git a/src/ellphi/_backend.py b/src/ellphi/_backend.py new file mode 100644 index 0000000..417747b --- /dev/null +++ b/src/ellphi/_backend.py @@ -0,0 +1,83 @@ +"""Backend selection helpers for tangency computations.""" + +from __future__ import annotations + +import os +from types import ModuleType +from typing import Literal + +from . import _tangency_cpp + +BackendName = Literal["python", "cpp"] + +__all__ = [ + "BackendName", + "available_backends", + "get_backend", + "set_backend", + "resolve_backend", + "has_cpp_backend", + "require_cpp_backend", +] + +_PREFERENCE = os.environ.get("ELLPHI_BACKEND", "auto").strip().lower() +if _PREFERENCE not in {"auto", "python", "cpp"}: + _PREFERENCE = "auto" + +_ACTIVE_BACKEND: BackendName | None = None + + +def available_backends() -> tuple[BackendName, ...]: + """Return the tuple of recognised backend names.""" + return ("python", "cpp") + + +def _ensure_cpp() -> ModuleType | None: + """Attempt to load the C++ backend and return the module on success.""" + return _tangency_cpp.get_module() + + +def has_cpp_backend() -> bool: + """Return ``True`` when the compiled backend can be used.""" + return _ensure_cpp() is not None + + +def require_cpp_backend() -> ModuleType: + """Return the compiled backend module or raise if unavailable.""" + module = _ensure_cpp() + if module is None: + error = _tangency_cpp.get_error() + if error is None: + raise RuntimeError("C++ backend is not available") + raise RuntimeError("Failed to load C++ backend") from error + return module + + +def resolve_backend(preference: str | None) -> BackendName: + """Resolve a backend name from user preference.""" + pref = (preference or _PREFERENCE).strip().lower() + if pref not in {"auto", "python", "cpp"}: + raise ValueError(f"Unknown backend '{preference}'") + if pref == "python": + return "python" + if pref == "cpp": + # Explicit request: raise on failure to make debugging clear. + require_cpp_backend() + return "cpp" + # Automatic selection prefers the compiled backend but falls back gracefully. + return "cpp" if has_cpp_backend() else "python" + + +def get_backend() -> BackendName: + """Return the currently active backend, initialising on first use.""" + global _ACTIVE_BACKEND + if _ACTIVE_BACKEND is None: + _ACTIVE_BACKEND = resolve_backend(None) + return _ACTIVE_BACKEND + + +def set_backend(name: BackendName) -> None: + """Update the globally active backend.""" + global _ACTIVE_BACKEND + backend = resolve_backend(name) + _ACTIVE_BACKEND = backend diff --git a/src/ellphi/_tangency_cpp.py b/src/ellphi/_tangency_cpp.py new file mode 100644 index 0000000..c4fb827 --- /dev/null +++ b/src/ellphi/_tangency_cpp.py @@ -0,0 +1,207 @@ +"""Lazy loader for the optional C++ tangency backend.""" + +from __future__ import annotations + +import ctypes +import subprocess +import sysconfig +import threading +from pathlib import Path +from types import ModuleType +from typing import Optional + +import numpy + +__all__ = ["is_available", "load", "get_module", "get_error"] + +_LOCK = threading.Lock() +_MODULE: ModuleType | None = None +_ERROR: Exception | None = None + + +class _TangencyResult(ctypes.Structure): + _fields_ = [ + ("t", ctypes.c_double), + ("point_x", ctypes.c_double), + ("point_y", ctypes.c_double), + ("mu", ctypes.c_double), + ] + + +def _shared_library_path() -> Path: + source = Path(__file__).with_name("_tangency_cpp_impl.cpp") + suffix = sysconfig.get_config_var("SHLIB_SUFFIX") or ".so" + return source.with_suffix(suffix) + + +def _needs_recompile(source: Path, library: Path) -> bool: + if not library.exists(): + return True + return library.stat().st_mtime < source.stat().st_mtime + + +def _compile_library() -> Path: + source = Path(__file__).with_name("_tangency_cpp_impl.cpp") + library = _shared_library_path() + + if not _needs_recompile(source, library): + return library + + cmd = [ + "g++", + "-std=c++17", + "-O3", + "-shared", + "-fPIC", + str(source), + "-o", + str(library), + ] + subprocess.run(cmd, check=True) + return library + + +def _error_from_code(code: int) -> Exception: + if code == 1: + return ZeroDivisionError("Degenerate conic (determinant zero)") + if code == 2: + return ValueError("Root is not bracketed for the selected interval") + if code == 3: + return ValueError("Unknown method") + if code == 4: + return ValueError("x0 must be provided for Newton method") + if code == 5: + return ZeroDivisionError("Zero derivative encountered in Newton method") + if code == 6: + return ValueError("Bracket must satisfy a < b") + return RuntimeError(f"Tangency solver failed with error code {code}") + + +def _wrap_library(lib: ctypes.CDLL) -> ModuleType: + tangency_solver = lib.tangency_solver + tangency_solver.argtypes = [ + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_double), + ctypes.c_char_p, + ctypes.POINTER(ctypes.c_double), + ctypes.c_double, + ctypes.c_int, + ctypes.POINTER(_TangencyResult), + ] + tangency_solver.restype = ctypes.c_int + + pdist_solver = lib.pdist_tangency_solver + pdist_solver.argtypes = [ + ctypes.POINTER(ctypes.c_double), + ctypes.c_int64, + ctypes.c_char_p, + ctypes.POINTER(ctypes.c_double), + ctypes.POINTER(ctypes.c_double), + ] + pdist_solver.restype = ctypes.c_int + + def tangency( + pcoef: numpy.ndarray, + qcoef: numpy.ndarray, + *, + method: str = "brentq+newton", + bracket: tuple[float, float] = (0.0, 1.0), + x0: float | None = None, + ) -> tuple[float, numpy.ndarray, float]: + p_arr = numpy.ascontiguousarray(pcoef, dtype=float) + q_arr = numpy.ascontiguousarray(qcoef, dtype=float) + if p_arr.shape != (6,) or q_arr.shape != (6,): + raise ValueError("Coefficient arrays must have shape (6,)") + + bracket_arr = numpy.ascontiguousarray(bracket, dtype=float) + if bracket_arr.shape != (2,): + raise ValueError("Bracket must be a pair of floats") + + result = _TangencyResult() + status = tangency_solver( + p_arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + q_arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + method.encode("ascii"), + bracket_arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + 0.0 if x0 is None else float(x0), + 0 if x0 is None else 1, + ctypes.byref(result), + ) + if status != 0: + raise _error_from_code(status) + + point = numpy.array([result.point_x, result.point_y], dtype=float) + return float(result.t), point, float(result.mu) + + def pdist_tangency( + coefficients: numpy.ndarray, + *, + method: str = "brentq+newton", + bracket: tuple[float, float] = (0.0, 1.0), + ) -> numpy.ndarray: + coef_arr = numpy.ascontiguousarray(coefficients, dtype=float) + if coef_arr.ndim != 2 or coef_arr.shape[1] != 6: + raise ValueError("Coefficient matrix must have shape (N, 6)") + + m = int(coef_arr.shape[0]) + out = numpy.empty(m * (m - 1) // 2, dtype=float) + bracket_arr = numpy.ascontiguousarray(bracket, dtype=float) + if bracket_arr.shape != (2,): + raise ValueError("Bracket must be a pair of floats") + + status = pdist_solver( + coef_arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + ctypes.c_int64(m), + method.encode("ascii"), + bracket_arr.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + out.ctypes.data_as(ctypes.POINTER(ctypes.c_double)), + ) + if status != 0: + raise _error_from_code(status) + return out + + module = ModuleType("_tangency_cpp_impl") + module.tangency = tangency # type: ignore[attr-defined] + module.pdist_tangency = pdist_tangency # type: ignore[attr-defined] + return module + + +def load() -> ModuleType: + """Import and return the compiled C++ module, raising on failure.""" + global _MODULE, _ERROR + if _MODULE is not None: + return _MODULE + if _ERROR is not None: + raise _ERROR + + with _LOCK: + if _MODULE is not None: + return _MODULE + if _ERROR is not None: + raise _ERROR + try: + library_path = _compile_library() + lib = ctypes.CDLL(str(library_path)) + _MODULE = _wrap_library(lib) + except Exception as exc: # pragma: no cover - exercised via Python fallback + _ERROR = exc + raise + return _MODULE + + +def get_module() -> Optional[ModuleType]: + """Return the compiled module if available; otherwise ``None``.""" + try: + return load() + except Exception: # pragma: no cover - exercised via Python fallback + return None + + +def is_available() -> bool: + """Return ``True`` if the compiled backend can be imported.""" + return get_module() is not None + + +def get_error() -> Exception | None: + """Return the import error encountered when loading the C++ backend.""" + return _ERROR diff --git a/src/ellphi/_tangency_cpp_impl.cpp b/src/ellphi/_tangency_cpp_impl.cpp new file mode 100644 index 0000000..4a6578f --- /dev/null +++ b/src/ellphi/_tangency_cpp_impl.cpp @@ -0,0 +1,427 @@ +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using Coef = std::array; +using Point = std::array; + +constexpr int SUCCESS = 0; +constexpr int ERROR_DEGENERATE = 1; +constexpr int ERROR_BRACKET = 2; +constexpr int ERROR_METHOD = 3; +constexpr int ERROR_MISSING_X0 = 4; +constexpr int ERROR_ZERO_DERIVATIVE = 5; +constexpr int ERROR_INVALID_BRACKET = 6; + +inline void load_coef(const double* src, Coef& dst) { + for (std::size_t i = 0; i < dst.size(); ++i) { + dst[i] = src[i]; + } +} + +inline double quad_eval(const Coef& coef, const Point& center) { + const double a = coef[0]; + const double b = coef[1]; + const double c = coef[2]; + const double d = coef[3]; + const double e = coef[4]; + const double f = coef[5]; + + const double x = center[0]; + const double y = center[1]; + + return a * x * x + 2.0 * b * x * y + c * y * y + 2.0 * d * x + 2.0 * e * y + f; +} + +inline Coef pencil(const Coef& p, const Coef& q, double mu) { + Coef coef{}; + const double one_minus_mu = 1.0 - mu; + for (std::size_t i = 0; i < coef.size(); ++i) { + coef[i] = one_minus_mu * p[i] + mu * q[i]; + } + return coef; +} + +inline int center_from_coef(const Coef& coef, Point& point) { + const double a = coef[0]; + const double b = coef[1]; + const double c = coef[2]; + const double d = coef[3]; + const double e = coef[4]; + + const double det = a * c - b * b; + if (det == 0.0) { + return ERROR_DEGENERATE; + } + + point[0] = (b * e - c * d) / det; + point[1] = (b * d - a * e) / det; + return SUCCESS; +} + +inline int target(double mu, const Coef& p, const Coef& q, double& value) { + const Coef coef = pencil(p, q, mu); + Point center{}; + const int status = center_from_coef(coef, center); + if (status != SUCCESS) { + return status; + } + value = quad_eval(p, center) - quad_eval(q, center); + return SUCCESS; +} + +inline int target_prime(double mu, const Coef& p, const Coef& q, double& value) { + const Coef coef = pencil(p, q, mu); + const double a = coef[0]; + const double b = coef[1]; + const double c = coef[2]; + const double d = coef[3]; + const double e = coef[4]; + + const double det = a * c - b * b; + if (det == 0.0) { + return ERROR_DEGENERATE; + } + + Point center{}; + center[0] = (b * e - c * d) / det; + center[1] = (b * d - a * e) / det; + + const double diff0 = p[0] - q[0]; + const double diff1 = p[1] - q[1]; + const double diff2 = p[2] - q[2]; + const double diff3 = p[3] - q[3]; + const double diff4 = p[4] - q[4]; + + const double v0 = -(diff0 * center[0] + diff1 * center[1] + diff3); + const double v1 = -(diff1 * center[0] + diff2 * center[1] + diff4); + + const double numerator = c * v0 * v0 - 2.0 * b * v0 * v1 + a * v1 * v1; + value = 2.0 * numerator / det; + return SUCCESS; +} + +inline int bisect(const Coef& p, const Coef& q, double a, double b, int maxiter, double tol, double& root) { + double fa = 0.0; + double fb = 0.0; + int status = target(a, p, q, fa); + if (status != SUCCESS) { + return status; + } + status = target(b, p, q, fb); + if (status != SUCCESS) { + return status; + } + if (fa == 0.0) { + root = a; + return SUCCESS; + } + if (fb == 0.0) { + root = b; + return SUCCESS; + } + if (fa * fb > 0.0) { + return ERROR_BRACKET; + } + + double left = a; + double right = b; + double mid = 0.5 * (left + right); + for (int iter = 0; iter < maxiter; ++iter) { + mid = 0.5 * (left + right); + double fm = 0.0; + status = target(mid, p, q, fm); + if (status != SUCCESS) { + return status; + } + if (std::abs(fm) < tol || 0.5 * (right - left) < tol) { + break; + } + if (fa * fm < 0.0) { + right = mid; + fb = fm; + } else { + left = mid; + fa = fm; + } + } + root = mid; + return SUCCESS; +} + +inline int brent(const Coef& p, const Coef& q, double a, double b, int maxiter, double tol, double& root) { + double fa = 0.0; + double fb = 0.0; + int status = target(a, p, q, fa); + if (status != SUCCESS) { + return status; + } + status = target(b, p, q, fb); + if (status != SUCCESS) { + return status; + } + if (fa == 0.0) { + root = a; + return SUCCESS; + } + if (fb == 0.0) { + root = b; + return SUCCESS; + } + if (fa * fb > 0.0) { + return ERROR_BRACKET; + } + + double left = a; + double right = b; + double c = a; + double fc = fa; + double s = right; + double d = 0.0; + bool mflag = true; + + for (int iter = 0; iter < maxiter; ++iter) { + if (fa != fc && fb != fc) { + s = (left * fb * fc) / ((fa - fb) * (fa - fc)) + + (right * fa * fc) / ((fb - fa) * (fb - fc)) + + (c * fa * fb) / ((fc - fa) * (fc - fb)); + } else { + s = right - fb * (right - left) / (fb - fa); + } + + const double condition1 = (s < (3.0 * left + right) * 0.25) || (s > right); + const double condition2 = mflag && std::abs(s - right) >= std::abs(right - c) * 0.5; + const double condition3 = !mflag && std::abs(s - right) >= std::abs(c - d) * 0.5; + const double condition4 = mflag && std::abs(right - c) < tol; + const double condition5 = !mflag && std::abs(c - d) < tol; + + if (condition1 || condition2 || condition3 || condition4 || condition5) { + s = 0.5 * (left + right); + mflag = true; + } else { + mflag = false; + } + + double fs = 0.0; + status = target(s, p, q, fs); + if (status != SUCCESS) { + return status; + } + + d = c; + c = right; + fc = fb; + + if (fa * fs < 0.0) { + right = s; + fb = fs; + } else { + left = s; + fa = fs; + } + + if (std::abs(fa) < std::abs(fb)) { + std::swap(left, right); + std::swap(fa, fb); + } + + if (std::abs(right - left) < tol) { + break; + } + } + + root = right; + return SUCCESS; +} + +inline int newton(const Coef& p, const Coef& q, double x0, int maxiter, double tol, double& root) { + double x = x0; + for (int iter = 0; iter < maxiter; ++iter) { + double fx = 0.0; + double dfx = 0.0; + int status = target(x, p, q, fx); + if (status != SUCCESS) { + return status; + } + status = target_prime(x, p, q, dfx); + if (status != SUCCESS) { + return status; + } + if (dfx == 0.0) { + return ERROR_ZERO_DERIVATIVE; + } + const double step = fx / dfx; + x -= step; + if (std::abs(step) < tol) { + break; + } + } + root = x; + return SUCCESS; +} + +struct TangencyState { + double t; + Point point; + double mu; +}; + +inline int tangency_impl( + const Coef& p, + const Coef& q, + const std::string& method, + double a, + double b, + bool has_x0, + double x0, + TangencyState& out +) { + if (!(a < b)) { + return ERROR_INVALID_BRACKET; + } + + constexpr double tol = 1e-12; + constexpr int brent_iter = 64; + constexpr int bisect_iter = 100; + constexpr int newton_iter = 3; + + double mu = 0.0; + + if (method == "brentq+newton") { + double mu0 = 0.0; + int status = brent(p, q, a, b, 8, tol, mu0); + if (status != SUCCESS) { + return status; + } + status = newton(p, q, mu0, newton_iter, tol, mu); + if (status == ERROR_ZERO_DERIVATIVE) { + mu = mu0; + } else if (status != SUCCESS) { + return status; + } + } else if (method == "brentq" || method == "brenth") { + int status = brent(p, q, a, b, brent_iter, tol, mu); + if (status != SUCCESS) { + return status; + } + } else if (method == "bisect") { + int status = bisect(p, q, a, b, bisect_iter, tol, mu); + if (status != SUCCESS) { + return status; + } + } else if (method == "newton") { + if (!has_x0) { + return ERROR_MISSING_X0; + } + int status = newton(p, q, x0, brent_iter, tol, mu); + if (status != SUCCESS) { + return status; + } + } else { + return ERROR_METHOD; + } + + const Coef coef = pencil(p, q, mu); + Point center{}; + int status = center_from_coef(coef, center); + if (status != SUCCESS) { + return status; + } + + const double value = quad_eval(coef, center); + out.t = std::sqrt(value < 0.0 ? 0.0 : value); + out.point = center; + out.mu = mu; + return SUCCESS; +} + +} // namespace + +extern "C" { + +struct TangencyResult { + double t; + double point_x; + double point_y; + double mu; +}; + +int tangency_solver( + const double* pcoef, + const double* qcoef, + const char* method, + const double* bracket, + double x0, + int has_x0, + TangencyResult* out +) { + if (pcoef == nullptr || qcoef == nullptr || bracket == nullptr || out == nullptr) { + return ERROR_METHOD; + } + + Coef p{}; + Coef q{}; + load_coef(pcoef, p); + load_coef(qcoef, q); + + const std::string method_str = method != nullptr ? std::string(method) : std::string("brentq+newton"); + const double a = bracket[0]; + const double b = bracket[1]; + + TangencyState state{}; + const int status = tangency_impl(p, q, method_str, a, b, has_x0 != 0, x0, state); + if (status != SUCCESS) { + return status; + } + + out->t = state.t; + out->point_x = state.point[0]; + out->point_y = state.point[1]; + out->mu = state.mu; + return SUCCESS; +} + +int pdist_tangency_solver( + const double* coefficients, + std::int64_t m, + const char* method, + const double* bracket, + double* out +) { + if (coefficients == nullptr || bracket == nullptr || out == nullptr) { + return ERROR_METHOD; + } + if (m <= 1) { + return SUCCESS; + } + + const std::string method_str = method != nullptr ? std::string(method) : std::string("brentq+newton"); + const double a = bracket[0]; + const double b = bracket[1]; + + for (std::int64_t i = 0; i < m; ++i) { + Coef p{}; + load_coef(coefficients + i * 6, p); + for (std::int64_t j = i + 1; j < m; ++j) { + Coef q{}; + load_coef(coefficients + j * 6, q); + TangencyState state{}; + const int status = tangency_impl(p, q, method_str, a, b, false, 0.0, state); + if (status != SUCCESS) { + return status; + } + const std::int64_t idx = m * i + j - ((i + 2) * (i + 1)) / 2; + out[idx] = state.t; + } + } + + return SUCCESS; +} + +} // extern "C" diff --git a/src/ellphi/solver.py b/src/ellphi/solver.py index 8f87eda..19fce17 100644 --- a/src/ellphi/solver.py +++ b/src/ellphi/solver.py @@ -16,6 +16,8 @@ from ellphi.ellcloud import EllipseCloud # from .ellcloud import EllipseCloud +from . import _backend + __all__ = [ "quad_eval", "pencil", @@ -23,6 +25,12 @@ "solve_mu", "tangency", "pdist_tangency", + "available_backends", + "get_backend", + "set_backend", + "has_cpp_backend", + "tangency_python", + "pdist_tangency_python", ] @@ -129,7 +137,7 @@ def solve_mu( # --------------------------------------------------------------------------- -def tangency( +def tangency_python( pcoef: numpy.ndarray, qcoef: numpy.ndarray, *, @@ -145,7 +153,40 @@ def tangency( return TangencyResult(t, numpy.asarray(point), mu) -def _pdist_tangency_serial(ellcloud: EllipseCloud) -> numpy.ndarray: +def tangency( + pcoef: numpy.ndarray, + qcoef: numpy.ndarray, + *, + method: str = "brentq+newton", + bracket: Tuple[float, float] = (0.0, 1.0), + x0: float | None = None, + backend: str | None = None, +) -> TangencyResult: + """Return the tangency result, dispatching to the selected backend.""" + + backend_name = _backend.resolve_backend(backend) + if backend_name == "cpp": + if method == "newton" and x0 is None: + raise ValueError("x0 must be provided for Newton method") + module = _backend.require_cpp_backend() + pcoef_arr = numpy.asarray(pcoef, dtype=float) + qcoef_arr = numpy.asarray(qcoef, dtype=float) + bracket_tuple = (float(bracket[0]), float(bracket[1])) + t_val, point_arr, mu_val = module.tangency( + pcoef_arr, + qcoef_arr, + method=method, + bracket=bracket_tuple, + x0=x0, + ) + return TangencyResult( + float(t_val), numpy.asarray(point_arr, dtype=float), float(mu_val) + ) + + return tangency_python(pcoef, qcoef, method=method, bracket=bracket, x0=x0) + + +def _pdist_tangency_serial_python(ellcloud: EllipseCloud) -> numpy.ndarray: """Serial implementation of pdist_tangency.""" m = len(ellcloud) n = m * (m - 1) // 2 @@ -153,18 +194,18 @@ def _pdist_tangency_serial(ellcloud: EllipseCloud) -> numpy.ndarray: for i in range(m): for j in range(i + 1, m): k = m * i + j - ((i + 2) * (i + 1)) // 2 - d[k] = tangency(ellcloud[i], ellcloud[j]).t + d[k] = tangency_python(ellcloud[i], ellcloud[j]).t return d -def _pdist_tangency_parallel( +def _pdist_tangency_parallel_python( ellcloud: EllipseCloud, n_jobs: int | None = -1 ) -> numpy.ndarray: """Parallel implementation of pdist_tangency.""" m = len(ellcloud) def get_pair_tangency(i, j): - return tangency(ellcloud[i], ellcloud[j]).t + return tangency_python(ellcloud[i], ellcloud[j]).t results = Parallel(n_jobs=n_jobs)( delayed(get_pair_tangency)(i, j) for i in range(m) for j in range(i + 1, m) @@ -172,7 +213,7 @@ def get_pair_tangency(i, j): return numpy.array(results, dtype=float) -def pdist_tangency( +def pdist_tangency_python( ellcloud: EllipseCloud, *, parallel: bool = True, n_jobs: int | None = -1 ) -> numpy.ndarray: """ @@ -192,5 +233,48 @@ def pdist_tangency( This is only used if `parallel` is True. Default is -1. """ if parallel: - return _pdist_tangency_parallel(ellcloud, n_jobs=n_jobs) - return _pdist_tangency_serial(ellcloud) + return _pdist_tangency_parallel_python(ellcloud, n_jobs=n_jobs) + return _pdist_tangency_serial_python(ellcloud) + + +def _coef_array_from_cloud(ellcloud: EllipseCloud | numpy.ndarray) -> numpy.ndarray: + """Return a ``(N, 6)`` array of coefficients from an ellipse cloud.""" + if isinstance(ellcloud, numpy.ndarray): + arr = numpy.asarray(ellcloud, dtype=float) + if arr.ndim != 2 or arr.shape[1] != 6: + raise ValueError("Coefficient matrix must have shape (N, 6)") + return arr + if hasattr(ellcloud, "coef"): + return numpy.asarray(ellcloud.coef, dtype=float) + + m = len(ellcloud) + coef = numpy.empty((m, 6), dtype=float) + for i in range(m): + coef[i] = numpy.asarray(ellcloud[i], dtype=float) + return coef + + +def pdist_tangency( + ellcloud: EllipseCloud, + *, + parallel: bool = True, + n_jobs: int | None = -1, + backend: str | None = None, +) -> numpy.ndarray: + """Compute pairwise tangency distances using the requested backend.""" + + backend_name = _backend.resolve_backend(backend) + if backend_name == "cpp": + module = _backend.require_cpp_backend() + coef = _coef_array_from_cloud(ellcloud) + result = module.pdist_tangency(coef, method="brentq+newton", bracket=(0.0, 1.0)) + return numpy.asarray(result, dtype=float) + + return pdist_tangency_python(ellcloud, parallel=parallel, n_jobs=n_jobs) + + +# Re-export backend utilities for callers who need explicit control. +available_backends = _backend.available_backends +get_backend = _backend.get_backend +set_backend = _backend.set_backend +has_cpp_backend = _backend.has_cpp_backend diff --git a/tests/test_cpp_backend.py b/tests/test_cpp_backend.py new file mode 100644 index 0000000..4c83399 --- /dev/null +++ b/tests/test_cpp_backend.py @@ -0,0 +1,71 @@ +import numpy as np +import pytest + +from ellphi import coef_from_axes +from ellphi.solver import ( + TangencyResult, + has_cpp_backend, + pdist_tangency, + pdist_tangency_python, + tangency, + tangency_python, +) + +from .factories import random_cloud + + +pytestmark = pytest.mark.skipif( + not has_cpp_backend(), reason="C++ backend is not available" +) + + +def _assert_results_close(lhs: TangencyResult, rhs: TangencyResult) -> None: + np.testing.assert_allclose(lhs.t, rhs.t, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(lhs.point, rhs.point, rtol=1e-12, atol=1e-12) + np.testing.assert_allclose(lhs.mu, rhs.mu, rtol=1e-12, atol=1e-12) + + +@pytest.mark.parametrize( + "params", + [ + ((0.0, 0.0), (2.0, 0.0), (1.0, 1.0), (1.0, 1.0), 0.0, 0.0), + ((0.3, -0.7), (-1.1, 1.4), (1.2, 0.9), (0.8, 1.5), 0.4, 1.0), + ((-1.5, 0.2), (1.3, -0.4), (0.7, 1.6), (1.1, 0.5), 0.8, 0.3), + ], +) +def test_tangency_cpp_matches_python(params): + center_p, center_q, axes_p, axes_q, theta_p, theta_q = params + p = coef_from_axes(center_p, axes_p[0], axes_p[1], theta_p) + q = coef_from_axes(center_q, axes_q[0], axes_q[1], theta_q) + + cpp_res = tangency(p, q, backend="cpp") + py_res = tangency_python(p, q) + + _assert_results_close(cpp_res, py_res) + + +def test_random_tangencies_match(): + rng = np.random.default_rng(2025) + for _ in range(10): + center_p = rng.uniform(-1.0, 1.0, size=2) + center_q = rng.uniform(-1.0, 1.0, size=2) + axes_p = rng.uniform(0.5, 2.0, size=2) + axes_q = rng.uniform(0.5, 2.0, size=2) + theta_p, theta_q = rng.uniform(0.0, np.pi, size=2) + + p = coef_from_axes(center_p, axes_p[0], axes_p[1], theta_p) + q = coef_from_axes(center_q, axes_q[0], axes_q[1], theta_q) + + cpp_res = tangency(p, q, backend="cpp") + py_res = tangency_python(p, q) + + _assert_results_close(cpp_res, py_res) + + +def test_pdist_cpp_matches_python(rng): + cloud = random_cloud(rng, n_ellipses=12) + + cpp = pdist_tangency(cloud, backend="cpp") + py = pdist_tangency_python(cloud) + + np.testing.assert_allclose(cpp, py, rtol=1e-12, atol=1e-12) From 2d90857f0e56d43197d116c6decf075850e5c341 Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 22:32:12 +0900 Subject: [PATCH 2/7] Build tangency backend during packaging --- build.py | 10 ++++++ build_backend.py | 35 ++++++++++++++++++ build_helpers.py | 71 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 11 +++++- src/ellphi/_tangency_cpp.py | 42 ++++++---------------- 5 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 build.py create mode 100644 build_backend.py create mode 100644 build_helpers.py diff --git a/build.py b/build.py new file mode 100644 index 0000000..46c5ab1 --- /dev/null +++ b/build.py @@ -0,0 +1,10 @@ +"""Poetry build script that compiles the optional C++ backend.""" + +from __future__ import annotations + +from build_helpers import compile_cpp_backend + + +def build() -> None: + """Compile the C++ tangency backend before packaging.""" + compile_cpp_backend() diff --git a/build_backend.py b/build_backend.py new file mode 100644 index 0000000..8d58b6d --- /dev/null +++ b/build_backend.py @@ -0,0 +1,35 @@ +"""Custom PEP 517 backend that ensures the C++ library is built.""" + +from __future__ import annotations + +from typing import Any + +from poetry.core.masonry.api import ( + build_sdist as _poetry_build_sdist, + build_wheel as _poetry_build_wheel, + prepare_metadata_for_build_wheel as _poetry_prepare_metadata, +) + +from build_helpers import compile_cpp_backend, remove_compiled_backend + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + compile_cpp_backend() + return _poetry_build_wheel(wheel_directory, config_settings, metadata_directory) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + compile_cpp_backend() + return _poetry_prepare_metadata(metadata_directory, config_settings) + + +def build_sdist(directory: str, config_settings: dict[str, Any] | None = None) -> str: + remove_compiled_backend() + return _poetry_build_sdist(directory, config_settings) diff --git a/build_helpers.py b/build_helpers.py new file mode 100644 index 0000000..9d8a09e --- /dev/null +++ b/build_helpers.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +"""Shared helpers for building the compiled C++ backend.""" + +import os +import subprocess +import sysconfig +from pathlib import Path + +__all__ = [ + "project_root", + "shared_library_path", + "compile_cpp_backend", + "remove_compiled_backend", +] + + +def project_root() -> Path: + return Path(__file__).parent + + +def shared_library_path() -> Path: + suffix = sysconfig.get_config_var("SHLIB_SUFFIX") or ".so" + source = project_root() / "src" / "ellphi" / "_tangency_cpp_impl.cpp" + return source.with_suffix(suffix) + + +def compile_cpp_backend(force: bool = False) -> Path: + source = project_root() / "src" / "ellphi" / "_tangency_cpp_impl.cpp" + library = shared_library_path() + + if not source.exists(): + raise FileNotFoundError(f"Missing C++ source file: {source}") + + if ( + not force + and library.exists() + and library.stat().st_mtime >= source.stat().st_mtime + ): + return library + + library.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + "g++", + "-std=c++17", + "-O3", + "-shared", + str(source), + "-o", + str(library), + ] + if os.name != "nt": + cmd.insert(4, "-fPIC") + + try: + subprocess.run(cmd, check=True) + except FileNotFoundError as exc: + raise RuntimeError("g++ compiler is required to build the C++ backend") from exc + except ( + subprocess.CalledProcessError + ) as exc: # pragma: no cover - build time failure + raise RuntimeError("Failed to compile the C++ tangency backend") from exc + + return library + + +def remove_compiled_backend() -> None: + library = shared_library_path() + if library.exists(): + library.unlink() diff --git a/pyproject.toml b/pyproject.toml index c84dc68..7e07401 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,15 @@ dev = [ [tool.poetry] packages = [{ include = "ellphi", from = "src" }] package-mode = true +include = [ + { path = "src/ellphi/_tangency_cpp_impl*.so", format = "wheel" }, + { path = "src/ellphi/_tangency_cpp_impl*.dylib", format = "wheel" }, + { path = "src/ellphi/_tangency_cpp_impl*.dll", format = "wheel" }, + { path = "src/ellphi/_tangency_cpp_impl*.pyd", format = "wheel" }, +] + +[tool.poetry.build] +script = "build.py" [tool.poetry.group.dev.dependencies] pandas = "^2.2.0" @@ -45,5 +54,5 @@ pytest = "^8.4.1" [build-system] requires = ["poetry-core>=1.9.0"] -build-backend = "poetry.core.masonry.api" +build-backend = "build_backend" diff --git a/src/ellphi/_tangency_cpp.py b/src/ellphi/_tangency_cpp.py index c4fb827..27459db 100644 --- a/src/ellphi/_tangency_cpp.py +++ b/src/ellphi/_tangency_cpp.py @@ -3,7 +3,6 @@ from __future__ import annotations import ctypes -import subprocess import sysconfig import threading from pathlib import Path @@ -34,33 +33,6 @@ def _shared_library_path() -> Path: return source.with_suffix(suffix) -def _needs_recompile(source: Path, library: Path) -> bool: - if not library.exists(): - return True - return library.stat().st_mtime < source.stat().st_mtime - - -def _compile_library() -> Path: - source = Path(__file__).with_name("_tangency_cpp_impl.cpp") - library = _shared_library_path() - - if not _needs_recompile(source, library): - return library - - cmd = [ - "g++", - "-std=c++17", - "-O3", - "-shared", - "-fPIC", - str(source), - "-o", - str(library), - ] - subprocess.run(cmd, check=True) - return library - - def _error_from_code(code: int) -> Exception: if code == 1: return ZeroDivisionError("Degenerate conic (determinant zero)") @@ -180,12 +152,18 @@ def load() -> ModuleType: if _ERROR is not None: raise _ERROR try: - library_path = _compile_library() + library_path = _shared_library_path() lib = ctypes.CDLL(str(library_path)) _MODULE = _wrap_library(lib) - except Exception as exc: # pragma: no cover - exercised via Python fallback - _ERROR = exc - raise + except OSError as exc: # pragma: no cover - exercised via Python fallback + library_str = str(library_path) + error = ImportError( + "Compiled C++ tangency backend is missing. " + f"Expected shared library at '{library_str}'." + ) + error.__cause__ = exc + _ERROR = error + raise error return _MODULE From 730b0c1d810374a1f99b15965c8a209b1354401f Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 23:17:01 +0900 Subject: [PATCH 3/7] Switch packaging to setuptools backend --- build.py | 10 ----- build_backend.py | 35 ------------------ build_helpers.py | 71 ----------------------------------- pyproject.toml | 23 ++++++------ setup.py | 96 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 127 deletions(-) delete mode 100644 build.py delete mode 100644 build_backend.py delete mode 100644 build_helpers.py create mode 100644 setup.py diff --git a/build.py b/build.py deleted file mode 100644 index 46c5ab1..0000000 --- a/build.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Poetry build script that compiles the optional C++ backend.""" - -from __future__ import annotations - -from build_helpers import compile_cpp_backend - - -def build() -> None: - """Compile the C++ tangency backend before packaging.""" - compile_cpp_backend() diff --git a/build_backend.py b/build_backend.py deleted file mode 100644 index 8d58b6d..0000000 --- a/build_backend.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Custom PEP 517 backend that ensures the C++ library is built.""" - -from __future__ import annotations - -from typing import Any - -from poetry.core.masonry.api import ( - build_sdist as _poetry_build_sdist, - build_wheel as _poetry_build_wheel, - prepare_metadata_for_build_wheel as _poetry_prepare_metadata, -) - -from build_helpers import compile_cpp_backend, remove_compiled_backend - - -def build_wheel( - wheel_directory: str, - config_settings: dict[str, Any] | None = None, - metadata_directory: str | None = None, -) -> str: - compile_cpp_backend() - return _poetry_build_wheel(wheel_directory, config_settings, metadata_directory) - - -def prepare_metadata_for_build_wheel( - metadata_directory: str, - config_settings: dict[str, Any] | None = None, -) -> str: - compile_cpp_backend() - return _poetry_prepare_metadata(metadata_directory, config_settings) - - -def build_sdist(directory: str, config_settings: dict[str, Any] | None = None) -> str: - remove_compiled_backend() - return _poetry_build_sdist(directory, config_settings) diff --git a/build_helpers.py b/build_helpers.py deleted file mode 100644 index 9d8a09e..0000000 --- a/build_helpers.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -"""Shared helpers for building the compiled C++ backend.""" - -import os -import subprocess -import sysconfig -from pathlib import Path - -__all__ = [ - "project_root", - "shared_library_path", - "compile_cpp_backend", - "remove_compiled_backend", -] - - -def project_root() -> Path: - return Path(__file__).parent - - -def shared_library_path() -> Path: - suffix = sysconfig.get_config_var("SHLIB_SUFFIX") or ".so" - source = project_root() / "src" / "ellphi" / "_tangency_cpp_impl.cpp" - return source.with_suffix(suffix) - - -def compile_cpp_backend(force: bool = False) -> Path: - source = project_root() / "src" / "ellphi" / "_tangency_cpp_impl.cpp" - library = shared_library_path() - - if not source.exists(): - raise FileNotFoundError(f"Missing C++ source file: {source}") - - if ( - not force - and library.exists() - and library.stat().st_mtime >= source.stat().st_mtime - ): - return library - - library.parent.mkdir(parents=True, exist_ok=True) - - cmd = [ - "g++", - "-std=c++17", - "-O3", - "-shared", - str(source), - "-o", - str(library), - ] - if os.name != "nt": - cmd.insert(4, "-fPIC") - - try: - subprocess.run(cmd, check=True) - except FileNotFoundError as exc: - raise RuntimeError("g++ compiler is required to build the C++ backend") from exc - except ( - subprocess.CalledProcessError - ) as exc: # pragma: no cover - build time failure - raise RuntimeError("Failed to compile the C++ tangency backend") from exc - - return library - - -def remove_compiled_backend() -> None: - library = shared_library_path() - if library.exists(): - library.unlink() diff --git a/pyproject.toml b/pyproject.toml index 7e07401..5e8550f 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,15 +32,6 @@ dev = [ [tool.poetry] packages = [{ include = "ellphi", from = "src" }] package-mode = true -include = [ - { path = "src/ellphi/_tangency_cpp_impl*.so", format = "wheel" }, - { path = "src/ellphi/_tangency_cpp_impl*.dylib", format = "wheel" }, - { path = "src/ellphi/_tangency_cpp_impl*.dll", format = "wheel" }, - { path = "src/ellphi/_tangency_cpp_impl*.pyd", format = "wheel" }, -] - -[tool.poetry.build] -script = "build.py" [tool.poetry.group.dev.dependencies] pandas = "^2.2.0" @@ -52,7 +43,17 @@ mypy = "^1.8" scipy-stubs = { version = "^1.16.0.2", python = ">=3.11" } pytest = "^8.4.1" +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] +include = ["ellphi*"] + +[tool.setuptools.package-data] +ellphi = ["_tangency_cpp_impl.*"] + [build-system] -requires = ["poetry-core>=1.9.0"] -build-backend = "build_backend" +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d34d5f5 --- /dev/null +++ b/setup.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +"""Setuptools configuration with a custom C++ build step.""" + +import os +import subprocess +import sysconfig +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py as build_py_orig +from setuptools.command.develop import develop as develop_orig +from setuptools.command.sdist import sdist as sdist_orig + +PROJECT_ROOT = Path(__file__).parent +SOURCE_FILE = PROJECT_ROOT / "src" / "ellphi" / "_tangency_cpp_impl.cpp" + + +def _shared_library_path() -> Path: + suffix = sysconfig.get_config_var("SHLIB_SUFFIX") or ".so" + return SOURCE_FILE.with_suffix(suffix) + + +def _compile_cpp_backend(force: bool = False) -> Path: + if not SOURCE_FILE.exists(): + msg = f"Missing C++ source file: {SOURCE_FILE}" + raise FileNotFoundError(msg) + + library = _shared_library_path() + + if ( + not force + and library.exists() + and library.stat().st_mtime >= SOURCE_FILE.stat().st_mtime + ): + return library + + library.parent.mkdir(parents=True, exist_ok=True) + + cmd = [ + "g++", + "-std=c++17", + "-O3", + "-shared", + str(SOURCE_FILE), + "-o", + str(library), + ] + if os.name != "nt": + cmd.insert(4, "-fPIC") + + try: + subprocess.run(cmd, check=True) + except FileNotFoundError as exc: # pragma: no cover - build environment issue + msg = "g++ compiler is required to build the C++ backend" + raise RuntimeError(msg) from exc + except subprocess.CalledProcessError as exc: # pragma: no cover - build failure + msg = "Failed to compile the C++ tangency backend" + raise RuntimeError(msg) from exc + + return library + + +def _remove_compiled_backend() -> None: + library = _shared_library_path() + if library.exists(): + library.unlink() + + +class build_py(build_py_orig): + def run(self) -> None: # pragma: no cover - executed during packaging + _compile_cpp_backend(force=self.force) + super().run() + + +class develop(develop_orig): + def run(self) -> None: # pragma: no cover - executed during editable installs + _compile_cpp_backend(force=True) + super().run() + + +class sdist(sdist_orig): + def run(self) -> None: # pragma: no cover - executed during packaging + try: + _remove_compiled_backend() + finally: + super().run() + + +setup( + cmdclass={ + "build_py": build_py, + "develop": develop, + "sdist": sdist, + }, +) From 05d795337e4319a048e0a8392761759ae6430f8a Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 23:17:07 +0900 Subject: [PATCH 4/7] Make tangency backend build portable --- .github/workflows/cpp-smoke.yml | 47 ++++++++++++++++++++++ README.md | 18 +++++++++ setup.py | 69 +++++++++++++++++++++++++-------- 3 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/cpp-smoke.yml diff --git a/.github/workflows/cpp-smoke.yml b/.github/workflows/cpp-smoke.yml new file mode 100644 index 0000000..5973bcf --- /dev/null +++ b/.github/workflows/cpp-smoke.yml @@ -0,0 +1,47 @@ +name: C++ backend smoke test + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11"] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install package and test tools + run: python -m pip install . pytest + + - name: Run smoke tests + run: | + python - <<'PY' + import numpy as np + from ellphi import tangency + from ellphi.solver import has_cpp_backend + + assert has_cpp_backend(), "C++ backend failed to load" + p = np.array([0, 0, 1, 0, 0, -1], dtype=float) + q = np.array([0, 0, 1, 0, 0, -4], dtype=float) + res = tangency(p, q, backend="cpp") + print(res) + PY + + - name: Run backend regression tests + run: python -m pytest tests/test_cpp_backend.py diff --git a/README.md b/README.md index 6ceae9c..c74dd52 100644 --- a/README.md +++ b/README.md @@ -22,3 +22,21 @@ A PyPI release is in progress. Until then, install from GitHub: ```bash pip install git+https://github.com/t-uda/ellphi.git ``` + +### C++ toolchain support + +EllPHi ships a prebuilt C++ backend when you install from source. The build +step now relies on Python's compiler configuration, and our GitHub Actions +matrix exercises Linux, macOS, and Windows builds on every push. You will need +one of the following toolchains if you compile locally: + +* **Linux** – GCC or Clang with support for `-std=c++17`, `-O3`, `-fPIC`, and + shared libraries (`-shared`). +* **macOS** – Apple Clang (Xcode 15 or newer) with `-dynamiclib` and + `-undefined dynamic_lookup`. +* **Windows** – Microsoft Visual C++ 2019+ (MSVC) or compatible toolchains with + `/std:c++17` and `/LD`. + +The CI smoke tests ensure the packaged wheel loads the compiled backend on all +three platforms, so end users installing via `pip` receive a working shared +library by default. diff --git a/setup.py b/setup.py index d34d5f5..4018d7b 100644 --- a/setup.py +++ b/setup.py @@ -2,12 +2,18 @@ """Setuptools configuration with a custom C++ build step.""" -import os -import subprocess +import sys import sysconfig from pathlib import Path from setuptools import setup +from setuptools._distutils import ccompiler as distutils_ccompiler +from setuptools._distutils.errors import ( + CompileError, + DistutilsExecError, + LinkError, +) +from setuptools._distutils.sysconfig import customize_compiler from setuptools.command.build_py import build_py as build_py_orig from setuptools.command.develop import develop as develop_orig from setuptools.command.sdist import sdist as sdist_orig @@ -37,24 +43,55 @@ def _compile_cpp_backend(force: bool = False) -> Path: library.parent.mkdir(parents=True, exist_ok=True) - cmd = [ - "g++", - "-std=c++17", - "-O3", - "-shared", - str(SOURCE_FILE), - "-o", - str(library), - ] - if os.name != "nt": - cmd.insert(4, "-fPIC") + compiler = distutils_ccompiler.new_compiler() + customize_compiler(compiler) + + build_temp = PROJECT_ROOT / "build" / "temp" + build_temp.mkdir(parents=True, exist_ok=True) + + extra_compile_args: list[str] + extra_link_args: list[str] = [] + + if compiler.compiler_type == "msvc": + extra_compile_args = ["/std:c++17", "/O2"] + extra_link_args = ["/LD"] + else: + extra_compile_args = ["-std=c++17", "-O3"] + if sys.platform == "darwin": + raw_linker = getattr(compiler, "linker_so", []) + if isinstance(raw_linker, str): + linker_so = raw_linker.split() + else: + linker_so = list(raw_linker) + linker_so = [arg for arg in linker_so if arg not in {"-bundle", "-shared"}] + if "-dynamiclib" not in linker_so: + linker_so.insert(1, "-dynamiclib") + if "-undefined" not in linker_so: + linker_so.extend(["-undefined", "dynamic_lookup"]) + compiler.linker_so = linker_so + else: + extra_compile_args.append("-fPIC") + extra_link_args = ["-shared"] try: - subprocess.run(cmd, check=True) + objects = compiler.compile( + [str(SOURCE_FILE)], + output_dir=str(build_temp), + extra_postargs=extra_compile_args, + ) + compiler.link_shared_object( + objects, + str(library), + extra_postargs=extra_link_args, + ) except FileNotFoundError as exc: # pragma: no cover - build environment issue - msg = "g++ compiler is required to build the C++ backend" + msg = "C++ compiler is required to build the C++ backend" raise RuntimeError(msg) from exc - except subprocess.CalledProcessError as exc: # pragma: no cover - build failure + except ( + CompileError, + LinkError, + DistutilsExecError, + ) as exc: # pragma: no cover - build failure msg = "Failed to compile the C++ tangency backend" raise RuntimeError(msg) from exc From fee273a999be2253e4d73386f37f0addbd974abb Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 23:36:48 +0900 Subject: [PATCH 5/7] Add offline packaging backend for C++ tangency build --- build.py | 14 +++ build_backend.py | 238 +++++++++++++++++++++++++++++++++++++++++++++++ build_helpers.py | 131 ++++++++++++++++++++++++++ pyproject.toml | 20 ++-- 4 files changed, 390 insertions(+), 13 deletions(-) create mode 100644 build.py create mode 100644 build_backend.py create mode 100644 build_helpers.py diff --git a/build.py b/build.py new file mode 100644 index 0000000..424db54 --- /dev/null +++ b/build.py @@ -0,0 +1,14 @@ +"""Poetry build script that ensures the C++ backend is compiled.""" + +from __future__ import annotations + +from build_helpers import compile_cpp_backend + + +def build() -> None: + """Compile the optional C++ backend before packaging.""" + compile_cpp_backend(force=True) + + +if __name__ == "__main__": # pragma: no cover - manual execution hook + build() diff --git a/build_backend.py b/build_backend.py new file mode 100644 index 0000000..1c7ff64 --- /dev/null +++ b/build_backend.py @@ -0,0 +1,238 @@ +"""Custom PEP 517 backend that builds the ellphi package without third-party tooling.""" + +from __future__ import annotations + +import base64 +import csv +import hashlib +import re +import shutil +import sysconfig +import tarfile +import tempfile +from pathlib import Path +from typing import Iterable +from zipfile import ZIP_DEFLATED, ZipFile + +import tomllib + +from build_helpers import ( + PACKAGE_ROOT, + PROJECT_ROOT, + compile_cpp_backend, + remove_compiled_backend, +) + + +def _read_pyproject() -> dict: + return tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + +def _project_metadata() -> dict: + data = _read_pyproject()["project"] + dependencies = list(data.get("dependencies", [])) + return { + "name": data["name"], + "version": data["version"], + "summary": data.get("description", ""), + "requires_python": data.get("requires-python"), + "dependencies": dependencies, + } + + +def _normalise_distribution(name: str) -> str: + return re.sub(r"[-_.]+", "_", name).lower() + + +def _write_text(path: Path, lines: Iterable[str]) -> None: + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _wheel_tag() -> str: + platform = sysconfig.get_platform().replace("-", "_").replace(".", "_") + return f"py3-none-{platform}" + + +def _copy_package(destination: Path) -> None: + if destination.exists(): + shutil.rmtree(destination) + destination.mkdir(parents=True, exist_ok=True) + for path in PACKAGE_ROOT.rglob("*"): + if path.name == "__pycache__" or path.suffix == ".pyc": + continue + rel = path.relative_to(PACKAGE_ROOT) + target = destination / rel + if path.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + + +def _record_rows(root: Path) -> list[tuple[str, str, str]]: + records: list[tuple[str, str, str]] = [] + for path in sorted(root.rglob("*")): + if path.is_dir(): + continue + rel = path.relative_to(root).as_posix() + if rel.endswith("RECORD"): + continue + data = path.read_bytes() + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).decode("ascii") + digest = digest.rstrip("=") + records.append((rel, f"sha256={digest}", str(len(data)))) + return records + + +def build_wheel( + wheel_directory: str, + config_settings: dict | None = None, + metadata_directory: str | None = None, +) -> str: + del config_settings, metadata_directory + + meta = _project_metadata() + compile_cpp_backend(force=True) + + distribution = _normalise_distribution(meta["name"]) + tag = _wheel_tag() + wheel_name = f"{distribution}-{meta['version']}-{tag}.whl" + + wheel_dir = Path(wheel_directory) + wheel_dir.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmp: + build_root = Path(tmp) + package_dest = build_root / "ellphi" + _copy_package(package_dest) + + dist_info = build_root / f"{distribution}-{meta['version']}.dist-info" + dist_info.mkdir(parents=True, exist_ok=True) + + metadata_lines = [ + "Metadata-Version: 2.1", + f"Name: {meta['name']}", + f"Version: {meta['version']}", + ] + if meta["summary"]: + metadata_lines.append(f"Summary: {meta['summary']}") + if meta["requires_python"]: + metadata_lines.append(f"Requires-Python: {meta['requires_python']}") + for dep in meta["dependencies"]: + metadata_lines.append(f"Requires-Dist: {dep}") + _write_text(dist_info / "METADATA", metadata_lines) + + wheel_lines = [ + "Wheel-Version: 1.0", + "Generator: ellphi-build-backend", + "Root-Is-Purelib: false", + f"Tag: {tag}", + ] + _write_text(dist_info / "WHEEL", wheel_lines) + + (dist_info / "top_level.txt").write_text("ellphi\n", encoding="utf-8") + + record_path = dist_info / "RECORD" + records = _record_rows(build_root) + record_rel = record_path.relative_to(build_root).as_posix() + with record_path.open("w", newline="") as record_file: + writer = csv.writer(record_file) + writer.writerows(records) + writer.writerow([record_rel, "", ""]) + + wheel_path = wheel_dir / wheel_name + with ZipFile(wheel_path, "w", compression=ZIP_DEFLATED) as archive: + for path in sorted(build_root.rglob("*")): + if path.is_dir(): + continue + rel = path.relative_to(build_root).as_posix() + archive.write(path, rel) + + return wheel_name + + +def build_sdist( + sdist_directory: str, + config_settings: dict | None = None, +) -> str: + del config_settings + + meta = _project_metadata() + remove_compiled_backend() + + distribution = meta["name"] + archive_name = f"{distribution}-{meta['version']}" + sdist_dir = Path(sdist_directory) + sdist_dir.mkdir(parents=True, exist_ok=True) + target = sdist_dir / f"{archive_name}.tar.gz" + + with tempfile.TemporaryDirectory() as tmp: + staging = Path(tmp) / archive_name + staging.mkdir(parents=True, exist_ok=True) + + shutil.copy2(PROJECT_ROOT / "pyproject.toml", staging / "pyproject.toml") + for filename in [ + "README.md", + "LICENSE", + "setup.py", + "build_backend.py", + "build_helpers.py", + "build.py", + ]: + source = PROJECT_ROOT / filename + if source.exists(): + shutil.copy2(source, staging / filename) + + package_dest = staging / "src" / "ellphi" + _copy_package(package_dest) + + with tarfile.open(target, "w:gz") as tar: + tar.add(staging, arcname=archive_name) + + return target.name + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict | None = None, +) -> str: + del config_settings + + meta = _project_metadata() + distribution = _normalise_distribution(meta["name"]) + metadata_root = Path(metadata_directory) + dist_info = metadata_root / f"{distribution}-{meta['version']}.dist-info" + dist_info.mkdir(parents=True, exist_ok=True) + + metadata_lines = [ + "Metadata-Version: 2.1", + f"Name: {meta['name']}", + f"Version: {meta['version']}", + ] + if meta["summary"]: + metadata_lines.append(f"Summary: {meta['summary']}") + if meta["requires_python"]: + metadata_lines.append(f"Requires-Python: {meta['requires_python']}") + for dep in meta["dependencies"]: + metadata_lines.append(f"Requires-Dist: {dep}") + _write_text(dist_info / "METADATA", metadata_lines) + + wheel_lines = [ + "Wheel-Version: 1.0", + "Generator: ellphi-build-backend", + "Root-Is-Purelib: false", + f"Tag: {_wheel_tag()}", + ] + _write_text(dist_info / "WHEEL", wheel_lines) + + top_level = dist_info / "top_level.txt" + top_level.write_text("ellphi\n", encoding="utf-8") + + return dist_info.name + + +__all__ = [ + "build_wheel", + "build_sdist", + "prepare_metadata_for_build_wheel", +] diff --git a/build_helpers.py b/build_helpers.py new file mode 100644 index 0000000..a8bffc1 --- /dev/null +++ b/build_helpers.py @@ -0,0 +1,131 @@ +"""Helper utilities for building the packaged C++ backend.""" + +from __future__ import annotations + +import sys +import sysconfig +from pathlib import Path + +import os +import shlex +import shutil +import subprocess + +PROJECT_ROOT = Path(__file__).parent.resolve() +SRC_ROOT = PROJECT_ROOT / "src" +PACKAGE_ROOT = SRC_ROOT / "ellphi" +SOURCE_FILE = PACKAGE_ROOT / "_tangency_cpp_impl.cpp" + + +def _compiler_command() -> list[str]: + env_cxx = os.environ.get("CXX") + if env_cxx: + return shlex.split(env_cxx) + + cfg_cxx = sysconfig.get_config_var("CXX") + if isinstance(cfg_cxx, str) and cfg_cxx: + return shlex.split(cfg_cxx) + + for candidate in ("c++", "g++", "clang++"): + if shutil.which(candidate): + return [candidate] + + if sys.platform.startswith("win"): + cl = shutil.which("cl") + if cl: + return [cl] + + msg = ( + "Unable to locate a C++ compiler. Set the CXX environment variable to override." + ) + raise RuntimeError(msg) + + +def shared_library_path() -> Path: + """Return the expected path to the compiled shared library.""" + suffix = sysconfig.get_config_var("SHLIB_SUFFIX") or ".so" + return SOURCE_FILE.with_suffix(suffix) + + +def remove_compiled_backend() -> None: + """Delete the compiled backend if it exists.""" + library = shared_library_path() + if library.exists(): + library.unlink() + + +def compile_cpp_backend(force: bool = False) -> Path: + """Compile the optional C++ backend and return the shared library path.""" + if not SOURCE_FILE.exists(): + msg = f"Missing C++ source file: {SOURCE_FILE}" + raise FileNotFoundError(msg) + + library = shared_library_path() + if ( + library.exists() + and not force + and library.stat().st_mtime >= SOURCE_FILE.stat().st_mtime + ): + return library + + command = _compiler_command() + is_msvc = command and Path(command[0]).name.lower() == "cl" + + library.parent.mkdir(parents=True, exist_ok=True) + + if is_msvc: + output_flag = f"/Fe{library}" + cmd = [ + *command, + "/std:c++17", + "/O2", + "/LD", + output_flag, + str(SOURCE_FILE), + ] + else: + if sys.platform == "darwin": + cmd = [ + *command, + "-std=c++17", + "-O3", + "-dynamiclib", + str(SOURCE_FILE), + "-o", + str(library), + "-undefined", + "dynamic_lookup", + ] + else: + cmd = [ + *command, + "-std=c++17", + "-O3", + "-shared", + "-fPIC", + str(SOURCE_FILE), + "-o", + str(library), + ] + + try: + subprocess.run(cmd, check=True, cwd=str(PACKAGE_ROOT)) + except FileNotFoundError as exc: # pragma: no cover - build environment issue + msg = "C++ compiler is required to build the C++ backend" + raise RuntimeError(msg) from exc + except subprocess.CalledProcessError as exc: # pragma: no cover - build failure + msg = "Failed to compile the C++ tangency backend" + raise RuntimeError(msg) from exc + + return library + + +__all__ = [ + "PROJECT_ROOT", + "SRC_ROOT", + "PACKAGE_ROOT", + "SOURCE_FILE", + "shared_library_path", + "remove_compiled_backend", + "compile_cpp_backend", +] diff --git a/pyproject.toml b/pyproject.toml index 5e8550f..7e1109d 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ authors = [ license = { file = "LICENSE" } readme = "README.md" requires-python = ">=3.10" -dynamic = ["dependencies", "optional-dependencies"] dependencies = [ "numpy>=1.26", @@ -32,6 +31,10 @@ dev = [ [tool.poetry] packages = [{ include = "ellphi", from = "src" }] package-mode = true +include = ["src/ellphi/_tangency_cpp_impl.*"] + +[tool.poetry.build] +script = "build.py" [tool.poetry.group.dev.dependencies] pandas = "^2.2.0" @@ -43,17 +46,8 @@ mypy = "^1.8" scipy-stubs = { version = "^1.16.0.2", python = ">=3.11" } pytest = "^8.4.1" -[tool.setuptools] -package-dir = { "" = "src" } - -[tool.setuptools.packages.find] -where = ["src"] -include = ["ellphi*"] - -[tool.setuptools.package-data] -ellphi = ["_tangency_cpp_impl.*"] - [build-system] -requires = ["setuptools>=69", "wheel"] -build-backend = "setuptools.build_meta" +requires = [] +build-backend = "build_backend" +backend-path = ["."] From 14967d9af0dedc7f59d61012a469fd48700f1388 Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 23:48:35 +0900 Subject: [PATCH 6/7] Update CI matrix to supported Python versions --- .github/workflows/python-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 775df6b..1dd6f03 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 From 377c2f543a5abe28b36162738f2c10dc6ed0357f Mon Sep 17 00:00:00 2001 From: Tomoki UDA Date: Sat, 20 Sep 2025 23:48:44 +0900 Subject: [PATCH 7/7] Fix smoke test workflow on all platforms --- .github/workflows/cpp-smoke.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cpp-smoke.yml b/.github/workflows/cpp-smoke.yml index 5973bcf..ee0b93e 100644 --- a/.github/workflows/cpp-smoke.yml +++ b/.github/workflows/cpp-smoke.yml @@ -27,9 +27,10 @@ jobs: run: python -m pip install --upgrade pip - name: Install package and test tools - run: python -m pip install . pytest + run: python -m pip install . matplotlib pytest - name: Run smoke tests + shell: bash run: | python - <<'PY' import numpy as np