From db2b1e67584e098802a2ab70ee53fd048be00723 Mon Sep 17 00:00:00 2001 From: Pierre Chanial Date: Tue, 2 Dec 2025 14:15:12 +0100 Subject: [PATCH 1/6] New build architecture using meson, Numpy 2.0 compatibility --- .gitignore | 3 +- .pre-commit-config.yaml | 14 +- README.md | 25 +- meson.build | 40 ++ pyproject.toml | 25 +- setup.py | 22 -- src/meson.build | 7 + src/pyoperators/__init__.py | 1 + src/pyoperators/core.py | 59 +-- src/pyoperators/fft.py | 2 +- src/pyoperators/flags.py | 1 + src/pyoperators/iterative/algorithms.py | 1 + src/pyoperators/iterative/cg.py | 2 +- src/pyoperators/iterative/core.py | 5 +- src/pyoperators/iterative/dli.py | 1 + src/pyoperators/iterative/linesearch.py | 1 + src/pyoperators/iterative/meson.build | 16 + src/pyoperators/iterative/optimize.py | 1 + src/pyoperators/iterative/stopconditions.py | 1 - src/pyoperators/linear.py | 81 ++-- src/pyoperators/meson.build | 23 ++ src/pyoperators/nonlinear.py | 14 +- src/pyoperators/operators_mpi.py | 3 +- src/pyoperators/operators_pywt.py | 2 +- src/pyoperators/utils/fake_MPI.py | 1 + src/pyoperators/utils/meson.build | 43 +++ src/pyoperators/utils/misc.py | 27 +- .../utils/{ufuncs.c.src => ufuncs.c} | 360 +++++++++--------- tests/test_composition.py | 6 +- tests/test_partition.py | 2 +- 30 files changed, 483 insertions(+), 306 deletions(-) create mode 100644 meson.build delete mode 100644 setup.py create mode 100644 src/meson.build create mode 100644 src/pyoperators/iterative/meson.build create mode 100644 src/pyoperators/meson.build create mode 100644 src/pyoperators/utils/meson.build rename src/pyoperators/utils/{ufuncs.c.src => ufuncs.c} (51%) diff --git a/.gitignore b/.gitignore index aacd107b..7a4b1475 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/.idea/ *.pyc *.so .coverage @@ -7,7 +8,7 @@ dist _site htmlcov src/pyoperators/utils/cythonutils.c -src/pyoperators/utils/ufuncs.c __pycache__ *.egg-info +.venv*/ venv*/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c530f64f..ac989198 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,33 +1,33 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: "v3.3.1" + rev: "v3.21.2" hooks: - id: pyupgrade - args: ["--py38-plus"] + args: ["--py310-plus"] - repo: https://github.com/hadialqattan/pycln - rev: "v2.1.3" + rev: "v2.6.0" hooks: - id: pycln args: - --all - repo: https://github.com/PyCQA/isort - rev: '5.12.0' + rev: '7.0.0' hooks: - id: isort args: - --profile=black - repo: https://github.com/psf/black - rev: '23.1.0' + rev: '25.11.0' hooks: - id: black args: - --skip-string-normalization - repo: https://github.com/pre-commit/pre-commit-hooks - rev: 'v4.4.0' + rev: 'v6.0.0' hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -37,7 +37,7 @@ repos: - id: debug-statements - repo: https://github.com/pycqa/flake8 - rev: '6.0.0' + rev: '7.3.0' hooks: - id: flake8 name: flake 8 (src) diff --git a/README.md b/README.md index 967729f5..af9f7a54 100644 --- a/README.md +++ b/README.md @@ -123,10 +123,33 @@ IdentityOperator() ## Requirements -- python 3.8 +- Python >= 3.10 +- NumPy >= 2.0 +- SciPy >= 1.10 Optional requirements: - PyWavelets: wavelet transforms - pyfftw: Fast Fourier transforms - mpi4py: For MPI communication + +## Development + +To build from source, you'll need: + +- Meson >= 1.1.0 +- Ninja +- Cython >= 0.29.30 +- A C compiler + +Build the project: + +```bash +pip install -e . --no-build-isolation +``` + +Or build a wheel: + +```bash +python -m build +``` diff --git a/meson.build b/meson.build new file mode 100644 index 00000000..b174263c --- /dev/null +++ b/meson.build @@ -0,0 +1,40 @@ +project('pyoperators', + 'c', 'cython', + version: run_command('python', '-c', + 'from setuptools_scm import get_version; print(get_version())', + check: false).stdout().strip(), + meson_version: '>= 1.1.0', + default_options: [ + 'warning_level=2', + ], +) + +py = import('python').find_installation(pure: false) + +# Check Python version +if py.language_version().version_compare('< 3.10') + error('Python 3.10 or newer is required') +endif + +# Dependencies +py_dep = py.dependency() + +# Get NumPy include directory +incdir_numpy = run_command(py, + ['-c', 'import numpy; print(numpy.get_include())'], + check: true +).stdout().strip() + +# Create a NumPy dependency with system includes (to avoid Meson absolute path error) +numpy_inc = declare_dependency( + compile_args: ['-I' + incdir_numpy] +) + +# Compiler options +cc = meson.get_compiler('c') + +# Define NPY_NO_DEPRECATED_API macro for NumPy 2.0 compatibility +numpy_nodepr_api = ['-DNPY_NO_DEPRECATED_API=NPY_1_19_API_VERSION'] + +# Add subdirectories +subdir('src') diff --git a/pyproject.toml b/pyproject.toml index ca39026b..afcf2417 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [build-system] -build-backend = "setuptools.build_meta" +build-backend = "mesonpy" requires = [ - "Cython>=0.13", - "oldest-supported-numpy", - "setuptools==64.0.3", + "meson-python>=0.15.0", + "meson>=1.1.0", + "ninja", + "Cython>=0.29.30", + "numpy>=2.0.0", "setuptools_scm[toml]>=6.2", - "wheel", ] [project] @@ -24,10 +25,9 @@ keywords = [ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: C", "Programming Language :: Cython", "Development Status :: 5 - Production/Stable", @@ -35,12 +35,12 @@ classifiers = [ "Operating System :: OS Independent", "Topic :: Scientific/Engineering", ] -requires-python = ">=3.8" +requires-python = ">=3.10" license = {file = "LICENSE"} dependencies = [ "numexpr>=2", - "numpy>=1.16", - "scipy>=0.9", + "numpy>=2.0", + "scipy>=1.10", ] dynamic = ["version"] @@ -49,12 +49,13 @@ fft = ["pyfftw"] mpi = ["mpi4py"] wavelets = ["pywavelets>=0.4.0"] dev = [ - "pyfftw", + "ninja", "mpi4py", - "pywavelets>=0.4.0", + "pyfftw", "pytest", "pytest-cov", "pytest-mock", + "pywavelets>=0.4.0", "setuptools_scm", ] diff --git a/setup.py b/setup.py deleted file mode 100644 index ea12be70..00000000 --- a/setup.py +++ /dev/null @@ -1,22 +0,0 @@ -import numpy as np -from Cython.Build import cythonize -from numpy.distutils.core import setup # for the pre-processing of .c.src files -from setuptools import Extension - -extensions = [ - Extension( - 'pyoperators.utils.cythonutils', - sources=['src/pyoperators/utils/cythonutils.pyx'], - include_dirs=[np.get_include()], - define_macros=[('NPY_NO_DEPRECATED_API', 'NPY_1_7_API_VERSION')], - ), - Extension( - 'pyoperators.utils.ufuncs', - sources=['src/pyoperators/utils/ufuncs.c.src'], - define_macros=[('NPY_NO_DEPRECATED_API', 'NPY_1_7_API_VERSION')], - ), -] - -setup( - ext_modules=cythonize(extensions), -) diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 00000000..9b8b04c5 --- /dev/null +++ b/src/meson.build @@ -0,0 +1,7 @@ +# Install Python sources +python_sources = [ + '__init__.py', +] + +# Install the pyoperators package +subdir('pyoperators') diff --git a/src/pyoperators/__init__.py b/src/pyoperators/__init__.py index b8a464df..1eb27202 100644 --- a/src/pyoperators/__init__.py +++ b/src/pyoperators/__init__.py @@ -10,6 +10,7 @@ - operators_pywt : (optional) loaded if PyWavelets is present. """ + from importlib.metadata import version as _version from .core import ( diff --git a/src/pyoperators/core.py b/src/pyoperators/core.py index d08acdcb..e049c39f 100644 --- a/src/pyoperators/core.py +++ b/src/pyoperators/core.py @@ -3,6 +3,7 @@ which can be added, composed or multiplied by a scalar. See the Operator docstring for more information. """ + from __future__ import annotations import inspect @@ -759,7 +760,7 @@ def _find_common_type(dtypes): dtypes = [d for d in dtypes if d is not None] if len(dtypes) == 0: return None - return np.find_common_type(dtypes, []) + return np.result_type(*dtypes) def _generate_associated_operators(self): """ @@ -1233,16 +1234,20 @@ def _init_inout( flag_is = ( 'explicit' if self.shapein is not None - else 'implicit' - if self.reshapeout != Operator.reshapeout.__get__(self, type(self)) - else 'unconstrained' + else ( + 'implicit' + if self.reshapeout != Operator.reshapeout.__get__(self, type(self)) + else 'unconstrained' + ) ) flag_os = ( 'explicit' if self.shapeout is not None - else 'implicit' - if self.reshapein != Operator.reshapein.__get__(self, type(self)) - else 'unconstrained' + else ( + 'implicit' + if self.reshapein != Operator.reshapein.__get__(self, type(self)) + else 'unconstrained' + ) ) self._set_flags(shape_input=flag_is, shape_output=flag_os) @@ -1303,7 +1308,7 @@ def _validate_arguments(self, input, output): Return the input and output as ndarray instances. If required, allocate the output. """ - input = np.array(input, copy=False) + input = np.asarray(input) dtype = self._find_common_type([input.dtype, self.dtype]) input_ = None @@ -1779,9 +1784,11 @@ def __str__(self): # parentheses for AdditionOperator and BlockDiagonalOperator operands = [ - f'({o})' - if isinstance(o, (AdditionOperator, BlockDiagonalOperator)) - else str(o) + ( + f'({o})' + if isinstance(o, (AdditionOperator, BlockDiagonalOperator)) + else str(o) + ) for o in self.operands ] @@ -2440,9 +2447,11 @@ def _apply_rule_homothety(self, operands): """ return sum( ( - self._apply_rule_homothety_linear(list(group)) - if islinear - else list(group) + ( + self._apply_rule_homothety_linear(list(group)) + if islinear + else list(group) + ) for islinear, group in groupby(operands, lambda o: o.flags.linear) ), [], @@ -3470,9 +3479,9 @@ def _validate_partition_composition(op1, op2): ) ) - return None if pout is None else merge_none( - op1.partitionout, pout - ), None if pin is None else merge_none(op2.partitionin, pin) + return None if pout is None else merge_none(op1.partitionout, pout), ( + None if pin is None else merge_none(op2.partitionin, pin) + ) @staticmethod def _rule_operator_commutative(self, op, cls): @@ -4268,7 +4277,7 @@ def __init__(self, data, broadcast=None, dtype=None, **keywords): self, (HomothetyOperator, po.linear.MaskOperator) ): self.__class__ = po.linear.MaskOperator - self.__init__(~data.astype(np.bool8), broadcast=broadcast, **keywords) + self.__init__(~data.astype(bool), broadcast=broadcast, **keywords) return if nmones + nones == n: keywords['flags'] = self.validate_flags( @@ -4608,14 +4617,8 @@ def __init__(self, func, axis=None, dtype=None, skipna=False, **keywords): f'The input ufunc {func.__name__!r} has {func.nout} output ' f'arguments. Expected number is 1.' ) - if np.__version__ < '2': - if axis is None: - direct = lambda x, out: func.reduce(x.flat, 0, dtype, out) - else: - direct = lambda x, out: func.reduce(x, axis, dtype, out) - else: - direct = lambda x, out: func.reduce(x, axis, dtype, out, skipna=skipna) - elif isinstance(func, types.FunctionType): + direct = lambda x, out: func.reduce(x, axis, dtype, out) + elif callable(func): if hasattr(func, '__wrapped__'): func = func.__wrapped__ parameters = inspect.signature(func).parameters @@ -4636,6 +4639,10 @@ def direct(x, out): else: direct = lambda x, out: func(x, axis=axis, out=out, **kw) + + else: + raise TypeError(f'The input is not callable: {func}') + self.axis = axis Operator.__init__(self, direct=direct, dtype=dtype, **keywords) diff --git a/src/pyoperators/fft.py b/src/pyoperators/fft.py index 4018e090..a48c01ac 100644 --- a/src/pyoperators/fft.py +++ b/src/pyoperators/fft.py @@ -86,7 +86,7 @@ def __init__( if pyfftw is None: raise ImportError('The package pyfftw is not installed.') - kernel = np.array(kernel, dtype=dtype, copy=False) + kernel = np.asarray(kernel, dtype=dtype) dtype = kernel.dtype if dtype.kind not in ('f', 'c'): kernel = kernel.astype(float) diff --git a/src/pyoperators/flags.py b/src/pyoperators/flags.py index 7db2a2cb..c681e0f7 100644 --- a/src/pyoperators/flags.py +++ b/src/pyoperators/flags.py @@ -4,6 +4,7 @@ linear, square etc. """ + from collections import namedtuple diff --git a/src/pyoperators/iterative/algorithms.py b/src/pyoperators/iterative/algorithms.py index fec0895c..33d8862a 100644 --- a/src/pyoperators/iterative/algorithms.py +++ b/src/pyoperators/iterative/algorithms.py @@ -1,6 +1,7 @@ """ Implements iterative algorithm class. """ + from copy import copy import numpy as np diff --git a/src/pyoperators/iterative/cg.py b/src/pyoperators/iterative/cg.py index c5e7d99b..6d23b97f 100644 --- a/src/pyoperators/iterative/cg.py +++ b/src/pyoperators/iterative/cg.py @@ -75,7 +75,7 @@ def __init__( raise TypeError('The complex case is not yet implemented.') elif dtype.kind != 'f': dtype = np.dtype(float) - b = np.array(b, dtype, copy=False) + b = np.asarray(b, dtype=dtype) if x0 is None: x0 = zeros(b.shape, dtype) diff --git a/src/pyoperators/iterative/core.py b/src/pyoperators/iterative/core.py index 58b8ada7..1b1aed4a 100644 --- a/src/pyoperators/iterative/core.py +++ b/src/pyoperators/iterative/core.py @@ -217,7 +217,10 @@ def initialize(self): for var, info in self.info.items(): for n, b in zip(info['names'][skip_new:], self._initial_state[var]): # XXX FIXME: b should be aligned... - b = np.array(b, info['dtype'], order='c', copy=copy) + if copy: + b = np.array(b, info['dtype'], order='c', copy=True) + else: + b = np.asarray(b, dtype=info['dtype'], order='c') setattr(self, n, b) def next(self, n=1): diff --git a/src/pyoperators/iterative/dli.py b/src/pyoperators/iterative/dli.py index 6926a646..1c17e4b1 100644 --- a/src/pyoperators/iterative/dli.py +++ b/src/pyoperators/iterative/dli.py @@ -10,6 +10,7 @@ http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.165.8284&rep=rep1&type=pdf """ + from copy import copy import numpy as np diff --git a/src/pyoperators/iterative/linesearch.py b/src/pyoperators/iterative/linesearch.py index c6c6f566..c2f35e3b 100644 --- a/src/pyoperators/iterative/linesearch.py +++ b/src/pyoperators/iterative/linesearch.py @@ -15,6 +15,7 @@ - LineSearch, LineSearchArmijo, LineSearchWolfe1; LineSearchWolfe2 """ + import numpy as np from .criterions import Norm2 diff --git a/src/pyoperators/iterative/meson.build b/src/pyoperators/iterative/meson.build new file mode 100644 index 00000000..ff7ad9a3 --- /dev/null +++ b/src/pyoperators/iterative/meson.build @@ -0,0 +1,16 @@ +# Install Python sources for iterative subpackage +py.install_sources( + [ + '__init__.py', + 'algorithms.py', + 'cg.py', + 'core.py', + 'criterions.py', + 'dli.py', + 'lanczos.py', + 'linesearch.py', + 'optimize.py', + 'stopconditions.py', + ], + subdir: 'pyoperators/iterative' +) diff --git a/src/pyoperators/iterative/optimize.py b/src/pyoperators/iterative/optimize.py index f37d9ea0..ed564a3d 100644 --- a/src/pyoperators/iterative/optimize.py +++ b/src/pyoperators/iterative/optimize.py @@ -1,6 +1,7 @@ """ Wraps scipy.optimize.fmin_* algorithms using Criterion instances. """ + import numpy as np import scipy.optimize as opt diff --git a/src/pyoperators/iterative/stopconditions.py b/src/pyoperators/iterative/stopconditions.py index bc07e077..d8eed45d 100644 --- a/src/pyoperators/iterative/stopconditions.py +++ b/src/pyoperators/iterative/stopconditions.py @@ -3,7 +3,6 @@ """ - __all__ = ['StopCondition', 'MaxErrorStopCondition', 'MaxIterationStopCondition'] diff --git a/src/pyoperators/linear.py b/src/pyoperators/linear.py index db357dfb..40784da6 100644 --- a/src/pyoperators/linear.py +++ b/src/pyoperators/linear.py @@ -6,7 +6,7 @@ import numexpr import numpy as np import scipy.sparse as sp -import scipy.sparse.sparsetools as sps +from scipy.sparse import _sparsetools from scipy.sparse.linalg import eigsh from .core import ( @@ -159,7 +159,7 @@ def __init__( dtype = float_or_complex_dtype(data.dtype) else: dtype = np.dtype(dtype) - data = np.array(data, dtype=dtype, copy=False) + data = np.asarray(data, dtype=dtype) self.data = data self.naxesin = int(naxesin) @@ -605,30 +605,52 @@ def __init__(self, matrix, dtype=None, shapein=None, shapeout=None, **keywords): ) def direct(self, input, output, operation=operation_assignment): + if operation not in (operation_assignment, operator.iadd): + raise ValueError('Invalid reduction operation.') + input = input.ravel().astype(output.dtype) output = output.ravel() - if operation is operation_assignment: - output[...] = 0 - elif operation is not operator.iadd: - raise ValueError('Invalid reduction operation.') m = self.matrix - if isinstance(m, sp.dok_matrix): - for (i, j), v in m.items(): - output[i] += v * input[j] + + # Handle different sparse matrix formats + if not sp.issparse(m) or (fmt := m.format) not in [ + 'csr', + 'csc', + 'coo', + 'bsr', + 'dia', + ]: + # For other formats (DOK, LIL, etc.), fallback to @ operator + result = m @ input + if operation is operation_assignment: + output[:] = result + else: + output[:] += result return + + if operation is operation_assignment: + output.fill(0) + + # Use scipy.sparse._sparsetools for zero-copy operations M, N = m.shape - fn = getattr(sps, m.format + '_matvec') - if isinstance(m, (sp.csr_matrix, sp.csc_matrix)): - fn(M, N, m.indptr, m.indices, m.data, input, output) - elif isinstance(m, sp.coo_matrix): - fn(m.nnz, m.row, m.col, m.data, input, output) - elif isinstance(m, sp.bsr_matrix): + + if fmt == 'csr': + _sparsetools.csr_matvec(M, N, m.indptr, m.indices, m.data, input, output) + elif fmt == 'csc': + _sparsetools.csc_matvec(M, N, m.indptr, m.indices, m.data, input, output) + elif fmt == 'coo': + _sparsetools.coo_matvec(m.nnz, m.row, m.col, m.data, input, output) + elif fmt == 'bsr': R, C = m.blocksize - fn(M // R, N // C, R, C, m.indptr, m.indices, m.data.ravel(), input, output) - elif isinstance(m, sp.dia_matrix): - fn(M, N, len(m.offsets), m.data.shape[1], m.offsets, m.data, input, output) + _sparsetools.bsr_matvec( + M // R, N // C, R, C, m.indptr, m.indices, m.data.ravel(), input, output + ) + elif fmt == 'dia': + n_diag = len(m.offsets) + L = m.data.shape[1] + _sparsetools.dia_matvec(M, N, n_diag, L, m.offsets, m.data, input, output) else: - raise NotImplementedError() + assert False, 'unreachable' def todense(self, shapein=None, shapeout=None, inplace=False): return self.matrix.toarray() @@ -805,7 +827,7 @@ class MaskOperator(DiagonalBase): """ def __init__(self, data, broadcast=None, **keywords): - data = np.array(data, dtype=bool, copy=False) + data = np.asarray(data, dtype=bool) if broadcast is None: broadcast = 'scalar' if data.ndim == 0 else 'disabled' if broadcast == 'disabled': @@ -880,7 +902,7 @@ class PackOperator(PackBase): """ def __init__(self, data, broadcast='disabled', **keywords): - data = np.array(data, bool, copy=False) + data = np.asarray(data, dtype=bool) if np.all(data == data.flat[0]): if data.flat[0]: self.__class__ = DiagonalOperator @@ -921,7 +943,7 @@ class UnpackOperator(PackBase): """ def __init__(self, data, broadcast='disabled', **keywords): - data = np.array(data, bool, copy=False) + data = np.asarray(data, dtype=bool) if np.all(data == data.flat[0]): if data.flat[0]: self.__class__ = DiagonalOperator @@ -1076,12 +1098,12 @@ def __init__( a2 = np.asarray(a2) a3 = np.asarray(a3) if dtype is None: - dtype = np.find_common_type( - [float_dtype(a.dtype) for a in (a1, a2, a3)], [] + dtype = np.result_type( + float_dtype(a1.dtype), float_dtype(a2.dtype), float_dtype(a3.dtype) ) - a1 = np.asarray(a1, dtype) - a2 = np.asarray(a2, dtype) - a3 = np.asarray(a3, dtype) + a1 = a1.astype(dtype, copy=False) + a2 = a2.astype(dtype, copy=False) + a3 = a3.astype(dtype, copy=False) if degrees: a1 = np.radians(a1) a2 = np.radians(a2) @@ -1320,10 +1342,7 @@ class SumOperator(ReductionOperator): """ def __init__(self, axis=None, dtype=None, skipna=True, **keywords): - if np.__version__ < '2': - func = np.nansum if skipna else np.add - else: - func = np.add + func = np.add ReductionOperator.__init__( self, func, axis=axis, dtype=dtype, skipna=skipna, **keywords ) diff --git a/src/pyoperators/meson.build b/src/pyoperators/meson.build new file mode 100644 index 00000000..fe7d0332 --- /dev/null +++ b/src/pyoperators/meson.build @@ -0,0 +1,23 @@ +# Get all Python source files in this directory +py.install_sources( + [ + '__init__.py', + 'config.py', + 'core.py', + 'fft.py', + 'flags.py', + 'linear.py', + 'memory.py', + 'nonlinear.py', + 'operators_mpi.py', + 'operators_pywt.py', + 'proxy.py', + 'rules.py', + 'warnings.py', + ], + subdir: 'pyoperators' +) + +# Add subdirectories +subdir('iterative') +subdir('utils') diff --git a/src/pyoperators/nonlinear.py b/src/pyoperators/nonlinear.py index df0408c3..7c72906d 100644 --- a/src/pyoperators/nonlinear.py +++ b/src/pyoperators/nonlinear.py @@ -437,10 +437,7 @@ class MaxOperator(ReductionOperator): """ def __init__(self, axis=None, dtype=None, skipna=False, **keywords): - if np.__version__ < '2': - func = np.nanmax if skipna else np.max - else: - func = np.max + func = np.max ReductionOperator.__init__( self, func, axis=axis, dtype=dtype, skipna=skipna, **keywords ) @@ -475,10 +472,7 @@ class MinOperator(ReductionOperator): """ def __init__(self, axis=None, dtype=None, skipna=False, **keywords): - if np.__version__ < '2': - func = np.nanmin if skipna else np.min - else: - func = np.min + func = np.min ReductionOperator.__init__( self, func, axis=axis, dtype=dtype, skipna=skipna, **keywords ) @@ -868,9 +862,9 @@ def __init__(self, shape_, order='C', **keywords): self.order = order self.ndim = ndim if order == 'C': - self.coefs = np.cumproduct((1,) + shape_[:0:-1])[::-1] + self.coefs = np.cumprod((1,) + shape_[:0:-1])[::-1] elif order == 'F': - self.coefs = np.cumproduct((1,) + shape_[:-1]) + self.coefs = np.cumprod((1,) + shape_[:-1]) def _reshape_to1d(self, shape): return shape[:-1] diff --git a/src/pyoperators/operators_mpi.py b/src/pyoperators/operators_mpi.py index 7c8787c9..8f6f8cd5 100644 --- a/src/pyoperators/operators_mpi.py +++ b/src/pyoperators/operators_mpi.py @@ -3,6 +3,7 @@ from .core import IdentityOperator, Operator from .flags import inplace, linear, real, square from .utils import isalias, split +from .utils.misc import product from .utils.mpi import MPI, as_mpi, distribute_shape, timer_mpi __all__ = ['MPIDistributionGlobalOperator', 'MPIDistributionIdentityOperator'] @@ -56,7 +57,7 @@ def __init__(self, shapein, commout=None, **keywords): counts = [] offsets = [0] for s in split(shapein[0], commout.size): - n = (s.stop - s.start) * np.product(shapein[1:]) + n = (s.stop - s.start) * product(shapein[1:]) counts.append(n) offsets.append(offsets[-1] + n) offsets.pop() diff --git a/src/pyoperators/operators_pywt.py b/src/pyoperators/operators_pywt.py index 53fe2560..c81f6e43 100644 --- a/src/pyoperators/operators_pywt.py +++ b/src/pyoperators/operators_pywt.py @@ -1,4 +1,4 @@ -""" Wrap PyWavelets wavelet transforms into Operators. +"""Wrap PyWavelets wavelet transforms into Operators. For now only 1D and 2D wavelets are available. diff --git a/src/pyoperators/utils/fake_MPI.py b/src/pyoperators/utils/fake_MPI.py index ebcde4aa..c88c2364 100644 --- a/src/pyoperators/utils/fake_MPI.py +++ b/src/pyoperators/utils/fake_MPI.py @@ -1,6 +1,7 @@ """ MPI-wrapper module for non-MPI enabled platforms. """ + import builtins as _builtins from itertools import count as _count diff --git a/src/pyoperators/utils/meson.build b/src/pyoperators/utils/meson.build new file mode 100644 index 00000000..abe5a90d --- /dev/null +++ b/src/pyoperators/utils/meson.build @@ -0,0 +1,43 @@ +# Install Python sources for utils subpackage +py.install_sources( + [ + '__init__.py', + 'fake_MPI.py', + 'misc.py', + 'mpi.py', + 'testing.py', + ], + subdir: 'pyoperators/utils' +) + +# Extension 1: cythonutils (Cython extension) +cythonutils_source = custom_target( + 'cythonutils_c', + output: 'cythonutils.c', + input: 'cythonutils.pyx', + command: [ + py, '-m', 'cython', + '-3', + '--output-file', '@OUTPUT@', + '@INPUT@' + ] +) + +py.extension_module( + 'cythonutils', + cythonutils_source, + c_args: numpy_nodepr_api, + dependencies: [py_dep, numpy_inc], + install: true, + subdir: 'pyoperators/utils' +) + +# Extension 2: ufuncs (C extension) +py.extension_module( + 'ufuncs', + 'ufuncs.c', + c_args: numpy_nodepr_api, + dependencies: [py_dep, numpy_inc], + install: true, + subdir: 'pyoperators/utils' +) diff --git a/src/pyoperators/utils/misc.py b/src/pyoperators/utils/misc.py index edd31f7c..05acd016 100644 --- a/src/pyoperators/utils/misc.py +++ b/src/pyoperators/utils/misc.py @@ -235,10 +235,10 @@ def cast(arrays, dtype=None, order='c'): """ arrays = tuple(arrays) if dtype is None: - arrays_ = [np.array(a, copy=False) for a in arrays if a is not None] + arrays_ = [np.asarray(a) for a in arrays if a is not None] dtype = np.result_type(*arrays_) result = ( - np.array(a, dtype=dtype, order=order, copy=False) if a is not None else None + np.asarray(a, dtype=dtype, order=order) if a is not None else None for a in arrays ) return tuple(result) @@ -670,12 +670,13 @@ def least_greater_multiple(a, factors, out=None): values = 1 for v, p in zip(factors, powers): values = values * v**p + initial = ( + np.iinfo(values.dtype).max + if np.issubdtype(values.dtype, np.integer) + else np.inf + ) for v, o in it: - if np.__version__ < '2': - values_ = np.ma.MaskedArray(values, mask=values < v, copy=False) - o[...] = np.min(values_) - else: - o[...] = np.amin(values, where=values >= v) + o[...] = np.amin(values, where=values >= v, initial=initial) out = it.operands[1] if out.ndim == 0: return out.flat[0] @@ -793,7 +794,7 @@ def product(a): return r a = np.asarray(a) - return np.product(a, dtype=a.dtype) + return np.prod(a, dtype=a.dtype) def renumerate(iterable): @@ -1208,11 +1209,11 @@ def zip_broadcast(*args, **keywords): raise TypeError('Invalid keyword(s).') iter_str = keywords.get('iter_str', True) n = max( - 1 - if not isinstance(_, Iterable) or isinstance(_, str) and not iter_str - else len(_) - if hasattr(_, '__len__') - else sys.maxsize + ( + 1 + if not isinstance(_, Iterable) or isinstance(_, str) and not iter_str + else len(_) if hasattr(_, '__len__') else sys.maxsize + ) for _ in args ) diff --git a/src/pyoperators/utils/ufuncs.c.src b/src/pyoperators/utils/ufuncs.c similarity index 51% rename from src/pyoperators/utils/ufuncs.c.src rename to src/pyoperators/utils/ufuncs.c index cea9025b..5f52ec5c 100644 --- a/src/pyoperators/utils/ufuncs.c.src +++ b/src/pyoperators/utils/ufuncs.c @@ -1,4 +1,8 @@ -/*-*-c-*-*/ +/* + * NumPy ufuncs for pyoperators + * Converted from ufuncs.c.src to use C preprocessor macros instead of numpy.distutils templates + * This enables compatibility with NumPy 2.0+ and modern build systems like Meson + */ #include #include @@ -48,30 +52,30 @@ static void *null_data17[17] = {NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, * Complex abs(x)**2 * *********************/ -/**begin repeat - * complex types - * #ftype = npy_float, npy_double, npy_longdouble# - * #c = f, , l# - */ -NPY_NO_EXPORT void -abs2@c@(char **args, const npy_intp *dimensions, const npy_intp *steps, void *data) -{ - UNARY_LOOP { - const @ftype@ inr = *(@ftype@ *)ip; - const @ftype@ ini = ((@ftype@ *)ip)[1]; - *((@ftype@ *)op) = inr*inr + ini*ini; - } +/* Macro to generate abs2 functions for complex types */ +#define DEFINE_ABS2(ftype, suffix) \ +NPY_NO_EXPORT void \ +abs2##suffix(char **args, const npy_intp *dimensions, const npy_intp *steps, void *data) \ +{ \ + UNARY_LOOP { \ + const ftype inr = *(ftype *)ip; \ + const ftype ini = ((ftype *)ip)[1]; \ + *((ftype *)op) = inr*inr + ini*ini; \ + } \ +} \ + \ +NPY_NO_EXPORT void \ +abs2##suffix##_real(char **args, const npy_intp *dimensions, const npy_intp *steps, void *data) \ +{ \ + UNARY_LOOP { \ + const ftype in = *(ftype *)ip; \ + *((ftype *)op) = in * in; \ + } \ } -NPY_NO_EXPORT void -abs2@c@_real(char **args, const npy_intp *dimensions, const npy_intp *steps, void *data) -{ - UNARY_LOOP { - const @ftype@ in = *(@ftype@ *)ip; - *((@ftype@ *)op) = in * in; - } -} -/**end repeat**/ +DEFINE_ABS2(npy_float, f) +DEFINE_ABS2(npy_double, ) +DEFINE_ABS2(npy_longdouble, l) static PyUFuncGenericFunction abs2_funcs[6] = {&abs2f, &abs2, &abs2l, @@ -82,32 +86,33 @@ static PyUFuncGenericFunction abs2_funcs[6] = * Hard thresholding * *********************/ -/**begin repeat - * #type = npy_float, npy_double, npy_longdouble# - * #c = f,,l# - */ -NPY_NO_EXPORT void -hard_thresholding@c@(char **args, const npy_intp *dimensions, const npy_intp* steps, - void* data) -{ - npy_intp i; - npy_intp n = dimensions[0]; - char *in = args[0], *threshold = args[1], *out = args[2]; - npy_intp in_step = steps[0], threshold_step = steps[1], out_step = steps[2]; - - @type@ tmp; - - for (i = 0; i < n; i++) { - tmp = *(@type@ *)in; - tmp = (fabs@c@(tmp) > *(@type@ *)threshold) ? tmp : 0; - *((@type@ *)out) = tmp; - - in += in_step; - threshold += threshold_step; - out += out_step; - } +/* Macro to generate hard_thresholding functions */ +#define DEFINE_HARD_THRESHOLDING(type, suffix) \ +NPY_NO_EXPORT void \ +hard_thresholding##suffix(char **args, const npy_intp *dimensions, const npy_intp* steps, \ + void* data) \ +{ \ + npy_intp i; \ + npy_intp n = dimensions[0]; \ + char *in = args[0], *threshold = args[1], *out = args[2]; \ + npy_intp in_step = steps[0], threshold_step = steps[1], out_step = steps[2]; \ + \ + type tmp; \ + \ + for (i = 0; i < n; i++) { \ + tmp = *(type *)in; \ + tmp = (fabs##suffix(tmp) > *(type *)threshold) ? tmp : 0; \ + *((type *)out) = tmp; \ + \ + in += in_step; \ + threshold += threshold_step; \ + out += out_step; \ + } \ } -/**end repeat**/ + +DEFINE_HARD_THRESHOLDING(npy_float, f) +DEFINE_HARD_THRESHOLDING(npy_double, ) +DEFINE_HARD_THRESHOLDING(npy_longdouble, l) static PyUFuncGenericFunction hard_thresholding_funcs[3] = {&hard_thresholdingf, @@ -119,79 +124,87 @@ static PyUFuncGenericFunction hard_thresholding_funcs[3] = * Masking * ***********/ -/**begin repeat - * #TYPE = BYTE, UBYTE, SHORT, USHORT, INT, UINT, - * LONG, ULONG, LONGLONG, ULONGLONG, - * HALF, FLOAT, DOUBLE, LONGDOUBLE# - * #type = npy_byte, npy_ubyte, npy_short, npy_ushort, npy_int, npy_uint, - * npy_long, npy_ulong, npy_longlong, npy_ulonglong, - * npy_half, npy_float, npy_double, npy_longdouble# - */ -NPY_NO_EXPORT void -@TYPE@_masking(char **args, const npy_intp *dimensions, const npy_intp* steps, void* data) -{ - npy_intp i; - npy_intp n = dimensions[0]; - char *in = args[0], *mask = args[1], *out = args[2]; - npy_intp in_step = steps[0], mask_step = steps[1], out_step = steps[2]; - - if (in == out) { - for (i = 0; i < n; i++) { - if (*mask) - *((@type@ *)out) = 0; - mask += mask_step; - out += out_step; - } - } else { - for (i = 0; i < n; i++) { - if (*mask) - *((@type@ *)out) = 0; - else - *((@type@ *)out) = *(@type@ *)in; - in += in_step; - mask += mask_step; - out += out_step; - } - } +/* Macro to generate masking functions for real types */ +#define DEFINE_MASKING(TYPE, type) \ +NPY_NO_EXPORT void \ +TYPE##_masking(char **args, const npy_intp *dimensions, const npy_intp* steps, void* data) \ +{ \ + npy_intp i; \ + npy_intp n = dimensions[0]; \ + char *in = args[0], *mask = args[1], *out = args[2]; \ + npy_intp in_step = steps[0], mask_step = steps[1], out_step = steps[2]; \ + \ + if (in == out) { \ + for (i = 0; i < n; i++) { \ + if (*mask) \ + *((type *)out) = 0; \ + mask += mask_step; \ + out += out_step; \ + } \ + } else { \ + for (i = 0; i < n; i++) { \ + if (*mask) \ + *((type *)out) = 0; \ + else \ + *((type *)out) = *(type *)in; \ + in += in_step; \ + mask += mask_step; \ + out += out_step; \ + } \ + } \ } -/**end repeat**/ -/**begin repeat - * #TYPE = CFLOAT, CDOUBLE, CLONGDOUBLE# - * #type = npy_cfloat, npy_cdouble, npy_clongdouble# - * #ftype = npy_float, npy_double, npy_longdouble# - */ -NPY_NO_EXPORT void -@TYPE@_masking(char **args, const npy_intp *dimensions, const npy_intp* steps, void* data) -{ - npy_intp i; - npy_intp n = dimensions[0]; - char *in = args[0], *mask = args[1], *out = args[2]; - npy_intp in_step = steps[0], mask_step = steps[1], out_step = steps[2]; - - if (in == out) { - for (i = 0; i < n; i++) { - if (*mask) { - ((@ftype@ *)out)[0] = 0.; - ((@ftype@ *)out)[1] = 0.; - } - mask += mask_step; - out += out_step; - } - } else { - for (i = 0; i < n; i++) { - if (*mask) { - ((@ftype@ *)out)[0] = 0.; - ((@ftype@ *)out)[1] = 0.; - } else - *((@type@ *)out) = *(@type@ *)in; - in += in_step; - mask += mask_step; - out += out_step; - } - } +DEFINE_MASKING(BYTE, npy_byte) +DEFINE_MASKING(UBYTE, npy_ubyte) +DEFINE_MASKING(SHORT, npy_short) +DEFINE_MASKING(USHORT, npy_ushort) +DEFINE_MASKING(INT, npy_int) +DEFINE_MASKING(UINT, npy_uint) +DEFINE_MASKING(LONG, npy_long) +DEFINE_MASKING(ULONG, npy_ulong) +DEFINE_MASKING(LONGLONG, npy_longlong) +DEFINE_MASKING(ULONGLONG, npy_ulonglong) +DEFINE_MASKING(HALF, npy_half) +DEFINE_MASKING(FLOAT, npy_float) +DEFINE_MASKING(DOUBLE, npy_double) +DEFINE_MASKING(LONGDOUBLE, npy_longdouble) + +/* Macro to generate masking functions for complex types */ +#define DEFINE_MASKING_COMPLEX(TYPE, type, ftype) \ +NPY_NO_EXPORT void \ +TYPE##_masking(char **args, const npy_intp *dimensions, const npy_intp* steps, void* data) \ +{ \ + npy_intp i; \ + npy_intp n = dimensions[0]; \ + char *in = args[0], *mask = args[1], *out = args[2]; \ + npy_intp in_step = steps[0], mask_step = steps[1], out_step = steps[2]; \ + \ + if (in == out) { \ + for (i = 0; i < n; i++) { \ + if (*mask) { \ + ((ftype *)out)[0] = 0.; \ + ((ftype *)out)[1] = 0.; \ + } \ + mask += mask_step; \ + out += out_step; \ + } \ + } else { \ + for (i = 0; i < n; i++) { \ + if (*mask) { \ + ((ftype *)out)[0] = 0.; \ + ((ftype *)out)[1] = 0.; \ + } else \ + *((type *)out) = *(type *)in; \ + in += in_step; \ + mask += mask_step; \ + out += out_step; \ + } \ + } \ } -/**end repeat**/ + +DEFINE_MASKING_COMPLEX(CFLOAT, npy_cfloat, npy_float) +DEFINE_MASKING_COMPLEX(CDOUBLE, npy_cdouble, npy_double) +DEFINE_MASKING_COMPLEX(CLONGDOUBLE, npy_clongdouble, npy_longdouble) static PyUFuncGenericFunction masking_funcs[17] = {&BYTE_masking, &UBYTE_masking, @@ -227,38 +240,38 @@ static char masking_types[17*3] = {NPY_BYTE, NPY_BOOL, NPY_BYTE, * Conjugate multiplication * ****************************/ -/**begin repeat - * complex types - * #ftype = npy_float, npy_double, npy_longdouble# - * #c = f, , l# - */ -NPY_NO_EXPORT void -multiply_conjugate@c@(char **args, const npy_intp *dimensions, const npy_intp *steps, - void *data) -{ - BINARY_LOOP { - const @ftype@ in1r = *(@ftype@ *)ip1; - const @ftype@ in1i = ((@ftype@ *)ip1)[1]; - const @ftype@ in2r = ((@ftype@ *)ip2)[0]; - const @ftype@ in2i = ((@ftype@ *)ip2)[1]; - ((@ftype@ *)op1)[0] = in1r*in2r + in1i*in2i; - ((@ftype@ *)op1)[1] = -in1r*in2i + in1i*in2r; - } +/* Macro to generate multiply_conjugate functions */ +#define DEFINE_MULTIPLY_CONJUGATE(ftype, suffix) \ +NPY_NO_EXPORT void \ +multiply_conjugate##suffix(char **args, const npy_intp *dimensions, const npy_intp *steps, \ + void *data) \ +{ \ + BINARY_LOOP { \ + const ftype in1r = *(ftype *)ip1; \ + const ftype in1i = ((ftype *)ip1)[1]; \ + const ftype in2r = ((ftype *)ip2)[0]; \ + const ftype in2i = ((ftype *)ip2)[1]; \ + ((ftype *)op1)[0] = in1r*in2r + in1i*in2i; \ + ((ftype *)op1)[1] = -in1r*in2i + in1i*in2r; \ + } \ +} \ + \ +NPY_NO_EXPORT void \ +multiply_real_conjugate##suffix(char **args, const npy_intp *dimensions, const npy_intp *steps, \ + void *data) \ +{ \ + BINARY_LOOP { \ + const ftype in1r = ((ftype *)ip1)[0]; \ + const ftype in2r = ((ftype *)ip2)[0]; \ + const ftype in2i = ((ftype *)ip2)[1]; \ + ((ftype *)op1)[0] = in1r*in2r; \ + ((ftype *)op1)[1] = -in1r*in2i; \ + } \ } -NPY_NO_EXPORT void -multiply_real_conjugate@c@(char **args, const npy_intp *dimensions, const npy_intp *steps, - void *data) -{ - BINARY_LOOP { - const @ftype@ in1r = ((@ftype@ *)ip1)[0]; - const @ftype@ in2r = ((@ftype@ *)ip2)[0]; - const @ftype@ in2i = ((@ftype@ *)ip2)[1]; - ((@ftype@ *)op1)[0] = in1r*in2r; - ((@ftype@ *)op1)[1] = -in1r*in2i; - } -} -/**end repeat**/ +DEFINE_MULTIPLY_CONJUGATE(npy_float, f) +DEFINE_MULTIPLY_CONJUGATE(npy_double, ) +DEFINE_MULTIPLY_CONJUGATE(npy_longdouble, l) static PyUFuncGenericFunction multiply_conjugate_funcs[6] = {&multiply_conjugatef, @@ -273,32 +286,33 @@ static PyUFuncGenericFunction multiply_conjugate_funcs[6] = * Soft thresholding * *********************/ -/**begin repeat - * #type = npy_float, npy_double, npy_longdouble# - * #c = f,,l# - */ -NPY_NO_EXPORT void -soft_thresholding@c@(char **args, const npy_intp *dimensions, const npy_intp* steps, - void* data) -{ - npy_intp i; - npy_intp n = dimensions[0]; - char *in = args[0], *threshold = args[1], *out = args[2]; - npy_intp in_step = steps[0], threshold_step = steps[1], out_step = steps[2]; - - @type@ tmp; - - for (i = 0; i < n; i++) { - tmp = fabs@c@(*(@type@ *)in) - *(@type@ *)threshold; - tmp = (tmp > 0) ? tmp : 0; - *((@type@ *)out) = copysign@c@(tmp, *(@type@ *)in); - - in += in_step; - threshold += threshold_step; - out += out_step; - } +/* Macro to generate soft_thresholding functions */ +#define DEFINE_SOFT_THRESHOLDING(type, suffix) \ +NPY_NO_EXPORT void \ +soft_thresholding##suffix(char **args, const npy_intp *dimensions, const npy_intp* steps, \ + void* data) \ +{ \ + npy_intp i; \ + npy_intp n = dimensions[0]; \ + char *in = args[0], *threshold = args[1], *out = args[2]; \ + npy_intp in_step = steps[0], threshold_step = steps[1], out_step = steps[2]; \ + \ + type tmp; \ + \ + for (i = 0; i < n; i++) { \ + tmp = fabs##suffix(*(type *)in) - *(type *)threshold; \ + tmp = (tmp > 0) ? tmp : 0; \ + *((type *)out) = copysign##suffix(tmp, *(type *)in); \ + \ + in += in_step; \ + threshold += threshold_step; \ + out += out_step; \ + } \ } -/**end repeat**/ + +DEFINE_SOFT_THRESHOLDING(npy_float, f) +DEFINE_SOFT_THRESHOLDING(npy_double, ) +DEFINE_SOFT_THRESHOLDING(npy_longdouble, l) static PyUFuncGenericFunction soft_thresholding_funcs[3] = {&soft_thresholdingf, diff --git a/tests/test_composition.py b/tests/test_composition.py index 72577723..ae84fdbc 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -62,13 +62,13 @@ def direct(self, input, output): np.multiply(input, self.v, output) pool.clear() - op = np.product([Op(v) for v in [1]]) + op = np.prod([Op(v) for v in [1]]) assert op.__class__ is Op op(1) assert_equal(len(pool), 0) pool.clear() - op = np.product([Op(v) for v in [1, 2]]) + op = np.prod([Op(v) for v in [1, 2]]) assert op.__class__ is CompositionOperator assert_equal(op(1), 2) assert_equal(len(pool), 0) @@ -77,7 +77,7 @@ def direct(self, input, output): assert_equal(op([1]), 2) assert_equal(len(pool), 0) - op = np.product([Op(v) for v in [1, 2, 4]]) + op = np.prod([Op(v) for v in [1, 2, 4]]) assert op.__class__ is CompositionOperator pool.clear() diff --git a/tests/test_partition.py b/tests/test_partition.py index 4189f21b..a2b760fa 100644 --- a/tests/test_partition.py +++ b/tests/test_partition.py @@ -123,7 +123,7 @@ def test_block1(axis, shape): @pytest.mark.parametrize('axiss', [0, 1, 2]) def test_block2(axisp, axiss): shape = (3, 4, 5, 6) - i = np.arange(np.product(shape)).reshape(shape) + i = np.arange(np.prod(shape)).reshape(shape) op = BlockDiagonalOperator(shape[axisp] * [Stretch(axiss)], new_axisin=axisp) axisp_ = axisp if axisp >= 0 else axisp + 4 axiss_ = axiss if axisp_ > axiss else axiss + 1 From 4e9e5ca6a8b45b3ab3499f9a3dc1fec750706c8b Mon Sep 17 00:00:00 2001 From: Pierre Chanial Date: Fri, 5 Dec 2025 02:18:55 +0100 Subject: [PATCH 2/6] Update CI --- .github/workflows/ci.yml | 67 ++++++++++++++++++++++++++++++++ .github/workflows/pre-commit.yml | 26 ------------- pyproject.toml | 32 ++++++++------- 3 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..d9cf2f8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + pre-commit: + name: Pre-commit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Install dependencies + run: | + pip install --upgrade pip + pip install pre-commit + - name: Run pre-commit hooks + run: pre-commit run --all-files --show-diff-on-failure --color=always + + test: + name: Run tests for ${{ matrix.python }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15-intel, macos-14] + python: [cp310, cp311, cp312, cp313, cp314] # no fftw wheel for cp314t + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Build wheel + uses: pypa/cibuildwheel@v3.3.0 + env: + CIBW_BUILD: ${{ matrix.python }}-* + CIBW_BEFORE_TEST_MACOS: | + # Workaround: pyfftw wheels for Python 3.11/3.12 are incompatible with macOS 15 Intel + # Rebuild from source for these specific versions on macos-15-intel only + if [[ ${{ matrix.os }} == "macos-15-intel" && ${{ matrix.python }} =~ ^cp31[12]$ ]]; then + echo "Rebuilding pyfftw from source for Python ${{ matrix.python }} on macOS 15 Intel (wheel incompatibility)" + brew install fftw libomp + pip uninstall -y pyfftw + pip install --no-binary pyfftw pyfftw + python -c "import pyfftw; print('✓ pyfftw works')" + fi + CIBW_TEST_COMMAND: | + pip uninstall --yes mpi4py + python -c "import pyoperators; print('✓ pyoperators works')" + pytest {project}/tests --cov=pyoperators + pip install mpi4py openmpi + mpirun -np 2 --allow-run-as-root pytest {project}/tests/test_mpi_comms.py diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index 9ae5c491..00000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Pre-commit hooks - -on: - push: - branches: - - master - pull_request: - branches: - - master - -jobs: - pre-commit: - name: Pre-commit hooks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install "pre-commit" - - name: Run pre-commit - run: pre-commit run --all-files diff --git a/pyproject.toml b/pyproject.toml index afcf2417..7d5b25ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: C", "Programming Language :: Cython", "Development Status :: 5 - Production/Stable", @@ -44,24 +46,28 @@ dependencies = [ ] dynamic = ["version"] -[project.optional-dependencies] +[project.urls] +homepage = "https://pchanial.github.io/pyoperators" +repository = "https://github.com/pchanial/pyoperators" + +[dependency-groups] fft = ["pyfftw"] mpi = ["mpi4py"] wavelets = ["pywavelets>=0.4.0"] dev = [ - "ninja", - "mpi4py", - "pyfftw", - "pytest", + {include-group = "fft"}, + {include-group = "mpi"}, + {include-group = "wavelets"}, + "pytest>=6.0", "pytest-cov", "pytest-mock", - "pywavelets>=0.4.0", "setuptools_scm", ] -[project.urls] -homepage = "https://pchanial.github.io/pyoperators" -repository = "https://github.com/pchanial/pyoperators" +[tool.cibuildwheel] +skip = "*-musllinux_*" +test-groups = ["dev"] +environment = {PIP_PREFER_BINARY=1} [tool.coverage.report] exclude_lines = [ @@ -73,11 +79,9 @@ exclude_lines = [ show_missing = true skip_covered = true -[tool.pytest.ini_options] -addopts = "-ra --cov=pyoperators" -testpaths = [ - "tests", -] +[tool.pytest] +addopts = ["-ra", "--color", "yes"] +testpaths = ["tests"] [tool.setuptools_scm] version_scheme = "post-release" From d5917b3624f05b11b91f3191c75880006262ebbf Mon Sep 17 00:00:00 2001 From: Pierre Chanial Date: Fri, 5 Dec 2025 01:30:36 +0100 Subject: [PATCH 3/6] Fix for Python 3.14 --- tests/test_nbytes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_nbytes.py b/tests/test_nbytes.py index b86798ae..5d30aca9 100644 --- a/tests/test_nbytes.py +++ b/tests/test_nbytes.py @@ -43,7 +43,7 @@ class Op2(Operator): (sp.csc_matrix, 192), (sp.csr_matrix, 184), (sp.dia_matrix, 308), - (sp.dok_matrix, 2240 if sys.version_info >= (3, 8) else 2464), + (sp.dok_matrix, 2240 if (3, 8) <= sys.version_info < (3, 14) else 2464), ], ) def test_sparse(cls, expected): From fb2c73b1b4d208cc61662e1219ebb479ac9eb85a Mon Sep 17 00:00:00 2001 From: Pierre Chanial Date: Fri, 5 Dec 2025 01:40:45 +0100 Subject: [PATCH 4/6] Attempt to fix MacOS 14 floating point issue --- tests/test_linear.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_linear.py b/tests/test_linear.py index 436f238a..84c7aa2d 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -279,7 +279,7 @@ def totoeplitz(n, firstrow): s = SymmetricBandToeplitzOperator(n, firstrow) if firstrow == [1] or firstrow == [[2], [1]]: assert isinstance(s, DiagonalOperator) - assert_same(s.todense(), totoeplitz(n, firstrow).todense(), atol=1) + assert_same(s.todense(), totoeplitz(n, firstrow).todense(), atol=2) @pytest.mark.skipif( From 9a855a6cf92137426d7aa2069b48f50a7444712a Mon Sep 17 00:00:00 2001 From: Pierre Chanial Date: Fri, 5 Dec 2025 01:53:15 +0100 Subject: [PATCH 5/6] Fix invalid casts on MacOS --- tests/test_utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index ec4ed923..d91494fc 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import os +import sys import time import numpy as np @@ -432,7 +433,11 @@ def ref(x): nmones = nzeros = nones = 0 return nmones, nzeros, nones, nothers > 0, np.all(x == x.flat[0]) - value = np.asarray(value).astype(dtype) + try: + value = np.asarray(value).astype(dtype) + except FloatingPointError: + pytest.skip(f'Cannot cast {value} to {dtype} on {sys.platform}.') + assert inspect_special_values(value) == ref(value) From 848e7f2fe8d08681255180fe8040a78d02104c53 Mon Sep 17 00:00:00 2001 From: Pierre Chanial Date: Sun, 7 Dec 2025 11:20:53 +0100 Subject: [PATCH 6/6] Update CD --- .github/workflows/build.yml | 89 -------------------------------- .github/workflows/release.yml | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 89 deletions(-) delete mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 6ee49f7b..00000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Build - -on: - push: - branches: - - master - pull_request: - branches: - - master - release: - types: [published] - -jobs: - - build_sdist: - name: Build sdist - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Build sdist - run: pipx run build --sdist - - - uses: actions/upload-artifact@v3 - with: - path: dist/*.tar.gz - - build_wheels: - name: Build wheels (${{ matrix.python.version }}) on ${{ matrix.platform.os }}/${{ matrix.platform.arch }} - runs-on: ${{ matrix.platform.os }}-latest - strategy: - fail-fast: false - matrix: - platform: - - os: Ubuntu - arch: x86_64 - - os: macOS - arch: x86_64 - - os: macOS - arch: arm64 - python: - - version: "3.8" - cp: cp38 - - version: "3.9" - cp: cp39 - - version: "3.10" - cp: cp310 - - version: "3.11" - cp: cp311 - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: Setup Python - uses: actions/setup-python@v4 - - - name: Build wheels - uses: pypa/cibuildwheel@v2.9.0 - env: - CIBW_BUILD: ${{ matrix.python.cp }}-* - CIBW_SKIP: "*-musllinux_*" - CIBW_ARCHS: ${{ matrix.platform.arch }} - CIBW_BEFORE_TEST_LINUX: | - yum install -y openmpi-devel - MPICC=/lib64/openmpi/bin/mpicc pip install mpi4py - CIBW_BEFORE_TEST_MACOS: brew install openmpi - CIBW_TEST_EXTRAS: dev - CIBW_TEST_COMMAND: pytest {package}/tests - - - uses: actions/upload-artifact@v3 - with: - path: ./wheelhouse/*.whl - - upload_all: - needs: [build_sdist, build_wheels] - runs-on: ubuntu-latest - if: github.event_name == 'release' && github.event.action == 'published' - steps: - - uses: actions/download-artifact@v3 - with: - name: artifact - path: dist - - - uses: pypa/gh-action-pypi-publish@v1.5.1 - with: - password: ${{ secrets.PYPI_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b6add7af --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release to PyPI + +on: + release: + types: [published] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + + build_sdist: + name: Build sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check tag of last commit + run: | + echo "Current tag:" + git describe --tags --exact-match HEAD + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Build sdist + run: pipx run build --sdist + + - name: Update sdist artifact + uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz + + build_wheels: + name: Build wheel for ${{ matrix.python }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15-intel, macos-14] + python: [cp310, cp311, cp312, cp313, cp314] # no fftw wheel for cp314t + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Build wheel + uses: pypa/cibuildwheel@v3.3.0 + env: + CIBW_BUILD: ${{ matrix.python }}-* + CIBW_BEFORE_TEST_MACOS: | + # Workaround: pyfftw wheels for Python 3.11/3.12 are incompatible with macOS 15 Intel + # Rebuild from source for these specific versions on macos-15-intel only + if [[ ${{ matrix.os }} == "macos-15-intel" && ${{ matrix.python }} =~ ^cp31[12]$ ]]; then + echo "Rebuilding pyfftw from source for Python ${{ matrix.python }} on macOS 15 Intel (wheel incompatibility)" + brew install fftw libomp + pip uninstall -y pyfftw + pip install --no-binary pyfftw pyfftw + python -c "import pyfftw; print('✓ pyfftw works')" + fi + CIBW_TEST_COMMAND: | + pip install mpi4py openmpi + python -c "import pyoperators; print('✓ pyoperators works')" + mpirun -np 2 --allow-run-as-root pytest {project}/tests/test_mpi_comms.py + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.python }}-${{ matrix.os }} + path: ./wheelhouse/*.whl + + publish: + name: "Publish" + needs: [build_sdist, build_wheels] + permissions: + id-token: write + runs-on: ubuntu-latest + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + pattern: '*' + path: dist + merge-multiple: true + + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@release/v1