diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb2ae94..afb362d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,21 +13,16 @@ jobs: contents: read # required for checkout steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: "3.11" + enable-cache: true - - name: Build stubs - run: | - pip install mypy - stubgen --include-docstrings src/ -o src/ + - name: Build package + run: uv build - - name: Build wheels - run: | - pip install build - python3 -m build --wheel - - - name: Upload to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Publish to PyPI + # uv publish automatically detects the OIDC token in GitHub Actions + run: uv publish diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60e8a3a..7993ebe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,28 +13,24 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repo - uses: actions/checkout@v4 - - name: Run pre-commit - uses: pre-commit/action@v3.0.1 + uses: actions/checkout@v6 + - name: Run prek + uses: j178/prek-action@v2 build: + needs: [lint] runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.12"] + python-version: ["3.10", "3.14"] steps: - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 with: python-version: ${{ matrix.python-version }} - allow-prereleases: true - - - uses: actions/checkout@v4 - - name: Test with tox + - name: Test run: | - pip install tox - tox -- --cov npx --cov-report xml --cov-report term - - - name: Submit to codecov - uses: codecov/codecov-action@v4 - if: ${{ matrix.python-version == '3.9' }} + uv run pytest --cov npx --cov-report term diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96b0346..9fce577 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,9 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.4.10 + rev: v0.15.12 hooks: - - id: ruff + - id: ruff-check + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 @@ -10,13 +11,13 @@ repos: - id: prettier - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.2 hooks: - id: codespell # args: ["-L", "sur,nd"] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.20.2 hooks: - id: mypy files: src diff --git a/justfile b/justfile index e2de1b2..30aad1f 100644 --- a/justfile +++ b/justfile @@ -12,9 +12,12 @@ clean: @rm -rf src/*.egg-info/ build/ dist/ .tox/ .mypy_cache/ format: - ruff --fix src/ tests/ - black src/ tests/ + ruff format src/ tests/ + ruff check --fix src/ tests/ blacken-docs README.md lint: - pre-commit run --all + prek run --all-files + +test *args: + uv run pytest -W error {{args}} diff --git a/pyproject.toml b/pyproject.toml index f9d093b..999cfff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "npx" -version = "0.1.6" -authors = [{name = "Nico Schlömer", email = "nico.schloemer@gmail.com"}] +version = "0.1.7" +authors = [{ name = "Nico Schlömer", email = "nico.schloemer@gmail.com" }] description = "Some useful extensions for NumPy" readme = "README.md" -license = {file = "LICENSE"} +license = { file = "LICENSE" } classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", @@ -16,21 +16,23 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "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 :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering", "Topic :: Utilities", ] -requires-python = ">=3.7" +requires-python = ">=3.10" dependencies = [ "numpy >= 1.20.0", # "scipy >= 1.8", ] +[dependency-groups] +dev = ["pytest", "pytest-cov"] + [project.urls] Code = "https://github.com/sigma-py/npx" Issues = "https://github.com/sigma-py/npx/issues" @@ -38,13 +40,20 @@ Issues = "https://github.com/sigma-py/npx/issues" [tool.ruff] src = ["src", "tests"] line-length = 88 -select = ["ALL"] -ignore = [ - "ANN", "S101", "D", "T201", "ERA", "N803", "PLR2004" -# "ANN", "C901", "D", "E741", "ERA", "FBT", "INP001", -# "N", "PLR", "S101", "T201", "TID252", "TD", "FIX002" +lint.select = ["ALL"] +lint.ignore = [ + "ANN", + "COM812", + "S101", + "D", + "T201", + "ERA", + "N803", + "PLR2004", + # "ANN", "C901", "D", "E741", "ERA", "FBT", "INP001", + # "N", "PLR", "S101", "T201", "TID252", "TD", "FIX002" ] -target-version = "py38" +target-version = "py310" [tool.mypy] ignore_missing_imports = true diff --git a/src/npx/__init__.py b/src/npx/__init__.py index e0185b6..db42ab4 100644 --- a/src/npx/__init__.py +++ b/src/npx/__init__.py @@ -4,15 +4,14 @@ from ._unique import unique, unique_rows __all__ = [ + "add_at", "dot", + "isin_rows", + "mean", "outer", "solve", - "sum_at", - "add_at", "subtract_at", - "unique_rows", - "isin_rows", - "mean", + "sum_at", "unique", "unique_rows", ] diff --git a/src/npx/_helpers.py b/src/npx/_helpers.py new file mode 100644 index 0000000..ef303a7 --- /dev/null +++ b/src/npx/_helpers.py @@ -0,0 +1,18 @@ +import warnings +from functools import wraps + + +def deprecated(reason: str): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + warnings.warn( + f"{func.__name__}() is deprecated: {reason}", + DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/src/npx/_main.py b/src/npx/_main.py index ca2bf62..cc8379b 100644 --- a/src/npx/_main.py +++ b/src/npx/_main.py @@ -1,14 +1,9 @@ -from functools import reduce -from operator import mul +from math import prod import numpy as np from numpy.typing import ArrayLike - -# math.prod in 3.8 -# https://docs.python.org/3/library/math.html#math.prod -def _prod(a): - return reduce(mul, a, 1) +from ._helpers import deprecated def dot(a: ArrayLike, b: np.ndarray) -> np.ndarray: @@ -36,6 +31,7 @@ def solve(A: np.ndarray, x: np.ndarray) -> np.ndarray: return np.linalg.solve(A, x.reshape(x.shape[0], -1)).reshape(x.shape) +@deprecated("np.add.at is now equally fast. Use that.") def sum_at(a: ArrayLike, indices: ArrayLike, minlength: int): """Sums up values `a` with `indices` into an output array of at least length `minlength` while treating dimensionality correctly. It's a lot @@ -49,6 +45,10 @@ def sum_at(a: ArrayLike, indices: ArrayLike, minlength: int): `indices` may have arbitrary shape, too, but then `a` has to start out the same. (Those dimensions are flattened out in the computation.) """ + return _sum_at(a, indices, minlength) + + +def _sum_at(a: ArrayLike, indices: ArrayLike, minlength: int): a = np.asarray(a) indices = np.asarray(indices) @@ -65,7 +65,7 @@ def sum_at(a: ArrayLike, indices: ArrayLike, minlength: int): out_shape = (minlength, *a.shape[m:]) indices = indices.reshape(-1) - a = a.reshape(_prod(a.shape[:m]), _prod(a.shape[m:])) + a = a.reshape(prod(a.shape[:m]), prod(a.shape[m:])) # Cast to int; bincount doesn't work for uint64 yet # https://github.com/numpy/numpy/issues/17760 @@ -79,16 +79,22 @@ def sum_at(a: ArrayLike, indices: ArrayLike, minlength: int): ).T.reshape(out_shape) +@deprecated("np.add.at is now equally fast. Use that.") def add_at(a: ArrayLike, indices: ArrayLike, b: ArrayLike): + return _add_at(a, indices, b) + + +def _add_at(a: ArrayLike, indices: ArrayLike, b: ArrayLike): a = np.asarray(a) indices = np.asarray(indices) b = np.asarray(b) m = len(indices.shape) assert a.shape[1:] == b.shape[m:] - a += sum_at(b, indices, a.shape[0]) + a += _sum_at(b, indices, a.shape[0]) +@deprecated("np.subtract.at is now equally fast. Use that.") def subtract_at(a: ArrayLike, indices: ArrayLike, b: ArrayLike): b = np.asarray(b) - add_at(a, indices, -b) + _add_at(a, indices, -b) diff --git a/src/npx/_unique.py b/src/npx/_unique.py index 9cc3cbf..93827bc 100644 --- a/src/npx/_unique.py +++ b/src/npx/_unique.py @@ -1,10 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING import numpy as np if TYPE_CHECKING: + from collections.abc import Callable + from numpy.typing import ArrayLike @@ -49,6 +51,7 @@ def unique( def unique_rows(a: ArrayLike, **kwargs) -> np.ndarray | tuple[np.ndarray, ...]: + # return np.unique(a, axis=0, **kwargs) # The numpy alternative `np.unique(a, axis=0)` is slow; cf. # . a = np.asarray(a) diff --git a/tests/speedtest.py b/tests/speedtest.py index 9409e7f..c5c6611 100644 --- a/tests/speedtest.py +++ b/tests/speedtest.py @@ -1,3 +1,10 @@ +# /// script +# dependencies = [ +# "npx", +# "numpy", +# "perfplot", +# ] +# /// import numpy as np import perfplot @@ -9,33 +16,34 @@ def setup(n): - idx = rng.randomint(0, m, n) + idx = rng.integers(0, m, size=n) b = rng.random(n) return idx, b -def np_add_at(data): +def np_add_at(idx, b): a = np.zeros(m) - idx, b = data np.add.at(a, idx, b) return a -def npx_add_at(data): +def npx_add_at(idx, b): a = np.zeros(m) - idx, b = data npx.add_at(a, idx, b) return a -def npx_sum_at(data): - idx, b = data +def npx_sum_at(idx, b): return npx.sum_at(b, idx, minlength=m) +def bincount(idx, b): + return np.bincount(idx, weights=b, minlength=m) + + b = perfplot.bench( setup=setup, - kernels=[np_add_at, npx_add_at, npx_sum_at], + kernels=[np_add_at, npx_add_at, npx_sum_at, bincount], n_range=[2**k for k in range(23)], ) -b.save("perf-add-at.svg") +b.save("perf-add-at.png", transparent=False) diff --git a/tests/test_at.py b/tests/test_at.py index 02a6c6f..129c0d3 100644 --- a/tests/test_at.py +++ b/tests/test_at.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import npx @@ -6,7 +7,9 @@ def test_sum_at(): a = [1.0, 2.0, 3.0] idx = [0, 1, 0] - out = npx.sum_at(a, idx, minlength=4) + + with pytest.warns(DeprecationWarning, match=r"sum_at\(\) is deprecated.*"): + out = npx.sum_at(a, idx, minlength=4) tol = 1.0e-13 ref = np.array([4.0, 2.0, 0.0, 0.0]) @@ -17,7 +20,9 @@ def test_add_at(): a = [1.0, 2.0, 3.0] idx = [0, 1, 0] out = np.zeros(2) - npx.add_at(out, idx, a) + + with pytest.warns(DeprecationWarning, match=r"add_at\(\) is deprecated.*"): + npx.add_at(out, idx, a) tol = 1.0e-13 ref = np.array([4.0, 2.0]) @@ -28,7 +33,9 @@ def test_subtract_at(): a = [1.0, 2.0, 3.0] idx = [0, 1, 0] out = np.ones(2) - npx.subtract_at(out, idx, a) + + with pytest.warns(DeprecationWarning, match=r"subtract_at\(\) is deprecated.*"): + npx.subtract_at(out, idx, a) tol = 1.0e-13 ref = np.array([-3.0, -1.0]) diff --git a/tests/test_mean.py b/tests/test_mean.py index cef3b8b..fcbc58d 100644 --- a/tests/test_mean.py +++ b/tests/test_mean.py @@ -48,8 +48,8 @@ def test_mean_neg(p, ref): def test_errors(): a = [-1.0, -2.0, -3.0, -5.0] - with pytest.raises(TypeError, match="Non-integer p.*"): + with pytest.raises(TypeError, match=r"Non-integer p.*"): npx.mean(a, 0.5) - with pytest.raises(ValueError, match="p=0 only works with nonnegative x."): + with pytest.raises(ValueError, match=r"p=0 only works with nonnegative x."): npx.mean(a, 0) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 42fdd9b..0000000 --- a/tox.ini +++ /dev/null @@ -1,12 +0,0 @@ -[tox] -envlist = py3 -isolated_build = True - -[testenv] -deps = - pytest - pytest-codeblocks - pytest-cov -extras = all -commands = - pytest {posargs} --codeblocks