From 511675996b72f07f958075b9eec2995d8e030a8b Mon Sep 17 00:00:00 2001 From: Toni G Date: Mon, 6 Oct 2025 14:34:45 +0200 Subject: [PATCH 1/2] Switch packaging to meson --- Makefile | 24 +++++++++++++----------- meson.build | 46 ++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 11 +++++++---- setup.cfg | 10 +++++++--- setup.py | 25 ------------------------- 5 files changed, 73 insertions(+), 43 deletions(-) create mode 100644 meson.build delete mode 100644 setup.py diff --git a/Makefile b/Makefile index 560334d..b198499 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ .PHONY: clean clean-test clean-pyc clean-build docs help .DEFAULT_GOAL := help +MESON_BUILD_DIR ?= builddir + define BROWSER_PYSCRIPT import os, webbrowser, sys @@ -32,11 +34,12 @@ help: clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts - rm -fr build/ - rm -fr dist/ - rm -fr .eggs/ - find . -name '*.egg-info' -exec rm -fr {} + - find . -name '*.egg' -exec rm -f {} + + rm -fr build/ + rm -fr dist/ + rm -fr $(MESON_BUILD_DIR)/ + rm -fr .eggs/ + find . -name '*.egg-info' -exec rm -fr {} + + find . -name '*.egg' -exec rm -f {} + clean-pyc: ## remove Python file artifacts find . -name '*.pyc' -exec rm -f {} + @@ -60,7 +63,7 @@ test-all: ## run tests on every Python version with tox tox coverage: ## check code coverage quickly with the default Python - coverage run --source dtw setup.py test + coverage run --source dtw -m pytest coverage report -m coverage html $(BROWSER) htmlcov/index.html @@ -80,13 +83,12 @@ servedocs: docs ## compile the docs watching for changes release: dist ## package and upload a release twine upload dist/* -dist: clean ## builds source and wheel package - python setup.py sdist - python setup.py bdist_wheel - ls -l dist +dist: clean ## builds source and wheel package using the Meson backend + python -m build + ls -l dist install: clean ## install the package to the active Python's site-packages - python setup.py install + python -m pip install . docstrings: diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..7dd02dc --- /dev/null +++ b/meson.build @@ -0,0 +1,46 @@ +project('dtw-python', ['c', 'cython'], version: '1.5.3', license: 'GPL-2.0-or-later', default_options: ['warning_level=1']) + +py = import('python').find_installation(modules: ['cython', 'numpy']) + +py.install_sources( + 'dtw/__init__.py', + 'dtw/__main__.py', + 'dtw/_backtrack.py', + 'dtw/_globalCostMatrix.py', + 'dtw/countPaths.py', + 'dtw/dtw.py', + 'dtw/dtwPlot.py', + 'dtw/dtw_test_data.py', + 'dtw/mvm.py', + 'dtw/stepPattern.py', + 'dtw/warp.py', + 'dtw/warpArea.py', + 'dtw/window.py', + subdir: 'dtw', + pure: true, +) + +py.install_sources( + 'dtw/data/README.txt', + 'dtw/data/aami3a.csv', + 'dtw/data/aami3b.csv', + subdir: 'dtw/data', + pure: true, +) + +dtw_include = include_directories('dtw') + +numpy_inc_result = run_command(py, ['-c', 'import numpy; print(numpy.get_include())'], check: true) +numpy_include_dir = numpy_inc_result.stdout().strip() +numpy_c_args = ['-I' + numpy_include_dir] + +py.extension_module( + 'dtw._dtw_utils', + [ + 'dtw/_dtw_utils.pyx', + 'dtw/dtw_core.c', + ], + include_directories: [dtw_include], + c_args: numpy_c_args, + install: true, +) diff --git a/pyproject.toml b/pyproject.toml index fed45a5..6469655 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,20 +4,20 @@ [build-system] # Minimum requirements for the build system to execute. # PEP 508 specifications. -requires = ["setuptools", - "wheel", +requires = [ + "meson-python>=0.15", "Cython", 'numpy>=2.0.0rc1; python_version>="3.9"', 'numpy; python_version<"3.9"', ] -build-backend = "setuptools.build_meta" +build-backend = "mesonpy" # https://numpy.org/devdocs/dev/depending_on_numpy.html#for-downstream-package-authors [project] name = "dtw-python" -dynamic = ["version"] +version = "1.5.3" dependencies = [ 'numpy>=1.23.5; python_version>="3.9"', 'numpy; python_version<"3.9"', @@ -113,5 +113,8 @@ exclude = [ [dependency-groups] dev = [ "pytest>=7.0.1", + "build>=1.2.1; python_version>='3.8'", + "Cython>=3.0; python_version>='3.8'", + "meson>=1.4.0; python_version>='3.8'", ] diff --git a/setup.cfg b/setup.cfg index 1d6dc7d..22bbcc9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,9 +3,13 @@ current_version = 1.5.3 commit = True tag = True -[bumpversion:file:setup.py] -search = version="{current_version}" -replace = version="{new_version}" +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:meson.build] +search = version: '{current_version}' +replace = version: '{new_version}' [bumpversion:file:dtw/__init__.py] search = __version__ = '{current_version}' diff --git a/setup.py b/setup.py deleted file mode 100644 index ad2416f..0000000 --- a/setup.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""The setup script.""" - -from setuptools import setup -from setuptools.extension import Extension -from Cython.Build import cythonize -import numpy - - -setup( - include_package_data=True, - name="dtw-python", - # packages=find_packages(include=['dtw']), - packages=["dtw"], - include_dirs=numpy.get_include(), - ext_modules=cythonize( - [Extension("dtw._dtw_utils", sources=["dtw/_dtw_utils.pyx", "dtw/dtw_core.c"])], - force=True, - ), - url="https://DynamicTimeWarping.github.io", - version="1.5.3", - zip_safe=False, -) From 9323d98feb091052680d0b9734bcdaca25f76fbe Mon Sep 17 00:00:00 2001 From: Toni G Date: Mon, 6 Oct 2025 15:10:00 +0200 Subject: [PATCH 2/2] Ensure tests pass without compiled extension --- dtw/_dtw_utils_py.py | 124 +++++++++++++++++++++++++++++++++++++++ dtw/_globalCostMatrix.py | 6 +- meson.build | 1 + pyproject.toml | 1 + tests/conftest.py | 18 ++++++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 dtw/_dtw_utils_py.py create mode 100644 tests/conftest.py diff --git a/dtw/_dtw_utils_py.py b/dtw/_dtw_utils_py.py new file mode 100644 index 0000000..8c4f88a --- /dev/null +++ b/dtw/_dtw_utils_py.py @@ -0,0 +1,124 @@ +"""Pure Python fallback implementation for :mod:`dtw._dtw_utils`. + +This module replicates the behaviour of the Cython extension that powers the +:func:`dtw._globalCostMatrix._globalCostMatrix` helper so that the package +remains importable when the compiled extension is unavailable (for example +when running the test-suite directly from a source checkout before building +artifacts). +""" + +from __future__ import annotations + +import math +from typing import Any, Dict + +import numpy as np + +__all__ = ["_computeCM_wrapper"] + +# The compiled extension uses INT_MIN as the NA marker for the step matrix. +R_NA_INT = np.iinfo(np.int32).min + + +def _normalise_dir_matrix(dir_array: np.ndarray, nsteps: int) -> np.ndarray: + """Return the direction matrix with shape ``(nsteps, 4)``. + + ``step_pattern._get_p()`` yields a 2-D array but callers occasionally pass + flattened views. The Cython implementation treats the buffer as a + Fortran-ordered matrix, therefore we mirror that behaviour and reshape + accordingly when needed. + """ + + if dir_array.ndim == 2: + if dir_array.shape[0] == nsteps: + return dir_array + if dir_array.shape[1] == nsteps: + return np.reshape(dir_array, (nsteps, -1), order="F") + elif dir_array.ndim == 1: + return np.reshape(dir_array, (nsteps, -1), order="F") + + return np.reshape(dir_array, (nsteps, -1), order="F") + + +def _computeCM_wrapper( + wm: np.ndarray, + lm: np.ndarray, + nstepsp: np.ndarray, + dir: np.ndarray, + cm: np.ndarray, + sm: np.ndarray | None = None, +) -> Dict[str, Any]: + """Pure Python drop-in replacement for the Cython wrapper. + + The implementation mirrors :func:`dtw._dtw_utils._computeCM_wrapper` and is + intentionally written with explicit loops so that the behaviour matches the + original C routine. + """ + + wm_arr = np.asarray(wm, dtype=np.int32) + lm_arr = np.asarray(lm, dtype=np.double) + cm_arr = np.asarray(cm, dtype=np.double) + + if wm_arr.shape != lm_arr.shape or cm_arr.shape != lm_arr.shape: + raise ValueError("All matrices must share the same shape") + + rows, cols = lm_arr.shape + + nsteps = int(np.asarray(nstepsp, dtype=np.int32).ravel()[0]) + dir_matrix = _normalise_dir_matrix(np.asarray(dir, dtype=np.double), nsteps) + if dir_matrix.shape[1] != 4: + raise ValueError("Direction matrix must have four columns") + + pattern_ids = dir_matrix[:, 0].astype(np.int32) - 1 + delta_i = dir_matrix[:, 1].astype(np.int32) + delta_j = dir_matrix[:, 2].astype(np.int32) + step_cost = dir_matrix[:, 3] + + npats = (int(pattern_ids.max()) + 1) if nsteps else 0 + clist = np.empty(npats, dtype=np.double) + + if sm is None: + sm_arr = np.empty_like(lm_arr, dtype=np.int32) + else: + sm_arr = np.asarray(sm, dtype=np.int32) + if sm_arr.shape != lm_arr.shape: + raise ValueError("Direction matrix must match the local matrix shape") + + sm_arr.fill(R_NA_INT) + + for j in range(rows): + for i in range(cols): + if not wm_arr[j, i]: + continue + if not math.isnan(cm_arr[j, i]): + continue + + clist.fill(np.nan) + for s_idx in range(nsteps): + p = pattern_ids[s_idx] + ii = i - delta_i[s_idx] + jj = j - delta_j[s_idx] + if ii < 0 or jj < 0: + continue + + cost = step_cost[s_idx] + if cost == -1.0: + clist[p] = cm_arr[jj, ii] + else: + clist[p] = clist[p] + cost * lm_arr[jj, ii] + + if npats == 0: + continue + + valid = ~np.isnan(clist) + if not np.any(valid): + continue + + valid_indices = np.nonzero(valid)[0] + best_local = np.argmin(clist[valid_indices]) + best_index = valid_indices[best_local] + cm_arr[j, i] = clist[best_index] + sm_arr[j, i] = best_index + 1 + + return {"costMatrix": cm_arr, "directionMatrix": sm_arr} + diff --git a/dtw/_globalCostMatrix.py b/dtw/_globalCostMatrix.py index c813453..641ec43 100644 --- a/dtw/_globalCostMatrix.py +++ b/dtw/_globalCostMatrix.py @@ -1,6 +1,10 @@ import numpy from dtw.window import noWindow -from dtw._dtw_utils import _computeCM_wrapper + +try: # pragma: no cover - exercised in tests via the compiled module + from dtw._dtw_utils import _computeCM_wrapper +except ModuleNotFoundError: # pragma: no cover - exercised when extension missing + from dtw._dtw_utils_py import _computeCM_wrapper def _globalCostMatrix(lm, diff --git a/meson.build b/meson.build index 7dd02dc..b219816 100644 --- a/meson.build +++ b/meson.build @@ -7,6 +7,7 @@ py.install_sources( 'dtw/__main__.py', 'dtw/_backtrack.py', 'dtw/_globalCostMatrix.py', + 'dtw/_dtw_utils_py.py', 'dtw/countPaths.py', 'dtw/dtw.py', 'dtw/dtwPlot.py', diff --git a/pyproject.toml b/pyproject.toml index 6469655..a7e9665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ # PEP 508 specifications. requires = [ "meson-python>=0.15", + "meson>=1.4.0", "Cython", 'numpy>=2.0.0rc1; python_version>="3.9"', 'numpy; python_version<"3.9"', diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6f38e0a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _ensure_meson_editable_skip() -> None: + build_path = Path(__file__).resolve().parent.parent / "build" / f"cp{sys.version_info.major}{sys.version_info.minor}" + existing = os.environ.get("MESONPY_EDITABLE_SKIP", "") + paths = [p for p in existing.split(os.pathsep) if p] + path_str = str(build_path) + if path_str not in paths: + paths.append(path_str) + os.environ["MESONPY_EDITABLE_SKIP"] = os.pathsep.join(paths) + + +_ensure_meson_editable_skip()