diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f6542eb --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,130 @@ +name: Release + +# Builds on every PR; publishes only on a version tag. +# +# Uses PyPI Trusted Publishing (OIDC) — NO API token is stored anywhere. GitHub +# mints a short-lived identity token scoped to this workflow in this repository, +# and PyPI verifies it. Nothing long-lived exists to leak, and a stolen +# credential cannot be replayed from anywhere else. +# +# ── ONE-TIME SETUP (PyPI project Owner only) ──────────────────────────────── +# https://pypi.org/manage/project/ewstools/settings/publishing/ +# Owner : ThomasMBury +# Repository name : ewstools +# Workflow filename : release.yml +# Environment name : pypi +# +# Optional dry-run target, same form at https://test.pypi.org (separate account): +# Environment name : testpypi +# +# ── HOW TO RELEASE ────────────────────────────────────────────────────────── +# 0. (optional, recommended first time) Actions ▸ Release ▸ Run workflow +# ▸ target = testpypi — proves the whole path without touching real PyPI +# 1. bump `version` in pyproject.toml, land it on main +# 2. git tag v2.1.3 && git push origin v2.1.3 +# +# The tag must match pyproject.toml exactly; the build job fails loudly if not, +# BEFORE anything is published. + +on: + push: + tags: ['v*'] + pull_request: # build + metadata check only, never publishes + workflow_dispatch: + inputs: + target: + description: 'Where to publish (dry-run defaults to nowhere)' + required: true + default: 'none' + type: choice + options: ['none', 'testpypi', 'pypi'] + +permissions: + contents: read + +# One release at a time. Prevents a double-pushed tag, or a tag plus a manual +# dispatch, from racing two uploads at the same artefact. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build and check distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' # >=3.11 required: tomllib is stdlib there + + # The classic release failure is a tag that disagrees with the packaged + # version: you tag v2.1.3, ship 2.1.2, and PyPI either rejects it as a + # duplicate or accepts it under the wrong number. Catch it before build. + - name: Tag must match pyproject version + if: startsWith(github.ref, 'refs/tags/') + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(python -c "import tomllib,pathlib;print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")" + echo "tag=$TAG pyproject=$PKG" + if [ "$TAG" != "$PKG" ]; then + echo "::error::tag v$TAG does not match pyproject.toml version $PKG" + exit 1 + fi + + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build twine + python -m build + + # Catches broken long_description / bad classifiers BEFORE upload. PyPI + # rejects those at upload time, which on a tag means a failed release. + - name: Check metadata renders on PyPI + run: python -m twine check --strict dist/* + + - name: Confirm the built artefact imports + run: | + python -m pip install dist/*.whl + python -c "import ewstools; print('import OK:', ewstools.__file__)" + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-testpypi: + name: Publish to TestPyPI (dry run) + needs: build + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/ewstools + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: {name: dist, path: dist/} + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + + publish-pypi: + name: Publish to PyPI + needs: build + # Tags publish automatically; a manual dispatch must explicitly ask for pypi. + if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi') + runs-on: ubuntu-latest + # Named environment so a human approval can be required later (Settings ▸ + # Environments ▸ pypi ▸ required reviewers) without editing this file. + environment: + name: pypi + url: https://pypi.org/p/ewstools + permissions: + id-token: write # REQUIRED for trusted publishing; nothing else is + steps: + - uses: actions/download-artifact@v4 + with: {name: dist, path: dist/} + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 318258d..f6bfb00 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,8 +13,11 @@ jobs: # runs-on: ubuntu-20.04 runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + # Spans the supported range end to end. 3.13/3.14 are what catch modern-stack + # breakage (NumPy 2.x, pandas 3.x, SciPy 1.16+) that older runners never see. + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 @@ -28,8 +31,10 @@ jobs: pip install flake8 pytest pytest-cov pip --default-timeout=100 install -e . - name: Install TensorFlow - # TF <2.16 has no 3.12 wheels (3.12 support requires TF 2.16+/Keras 3) - if: matrix.python-version != '3.12' + # TF <2.16 has no wheels for 3.12+ (3.12 support requires TF 2.16+/Keras 3). + # Where TF is absent the deep-learning tests skip themselves via + # pytest.importorskip("tensorflow") — the rest of the suite still runs. + if: ${{ !contains(fromJSON('["3.12","3.13","3.14"]'), matrix.python-version) }} run: pip install "tensorflow>=2.10,<2.16" - name: Lint with flake8 run: | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9389d29 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to `ewstools` are documented here. +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.1.3] — unreleased + +Maintenance release. **No API changes and no behavioural changes** — existing code and +notebooks run unmodified. + +### Fixed + +- **Spectral EWS now work on modern SciPy / pandas.** `compute_spectrum()` (and therefore + `compute_smax()` and `compute_spec_type()`) raised + `KeyError: 'key of type tuple not found and not a MultiIndex'` on recent stacks. + SciPy ≥ 1.16 routes `signal.welch` through `ShortTimeFFT`, which slices its input as + `x[..., i0:i1]`; given a pandas `Series`, pandas ≥ 3.0 reads that tuple as a MultiIndex + key and raises rather than falling through to positional slicing. The series is now + converted to an array at the boundary. + The code fix landed on `main` in March 2026 but had not been published to PyPI, so every + released install still carried the break. This release ships it. + Reported in [#474](https://github.com/ThomasMBury/ewstools/issues/474). + +### Changed + +- **`numpy < 2.0` ceiling removed.** NumPy 2.x is supported and is now covered by CI. The + pin blocked installation alongside current scientific-Python environments — which is how + the above bug reached a user in the first place. + +### Added + +- **CI matrix extended to Python 3.13 and 3.14**, so modern-stack regressions surface in CI + rather than in users' notebooks. `fail-fast: false` so one failing version no longer masks + results for the others. + +### Verified + +Full test suite plus the spectral tutorial path, in clean containers, with dependencies +resolved from `pyproject.toml` as a user would get them: + +| Python | NumPy | pandas | SciPy | tests | spectral calls | +|---|---|---|---|---|---| +| 3.9.25 | 2.0.2 | 2.3.3 | 1.13.1 | 30 passed, 1 skipped | 6/6 | +| 3.12.13 | 2.5.1 | 3.0.3 | 1.18.0 | 30 passed, 1 skipped | 6/6 | +| 3.14.6 | 2.5.1 | 3.0.3 | 1.18.0 | 30 passed, 1 skipped | 6/6 | + +The single skip is the TensorFlow deep-learning test, which guards itself with +`pytest.importorskip` where TF has no wheels. + +Against released 2.1.2 in the same container, all four spectral entry points fail; on this +branch all four pass. diff --git a/ewstools/core.py b/ewstools/core.py index 7323877..84dd4df 100644 --- a/ewstools/core.py +++ b/ewstools/core.py @@ -54,6 +54,14 @@ import plotly.graph_objects as go from plotly.subplots import make_subplots +# EntropyHub 2.0 (the latest release) still uses `np.NaN`, which NumPy removed in +# 2.0 — so EH.K2En() and EH.CoSiEn() raise AttributeError on a modern stack even +# though ewstools itself is clean. Restore the alias before importing EntropyHub; +# `np.NaN` was only ever a spelling of `np.nan`, so this is not a behaviour change. +# Remove once EntropyHub ships a NumPy 2 compatible release (reported upstream). +if not hasattr(np, "NaN"): # pragma: no cover - depends on installed NumPy + np.NaN = np.nan + import EntropyHub as EH # For deprecating old functions diff --git a/pyproject.toml b/pyproject.toml index 42b88f6..37c931e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "ewstools" -version = "2.1.2" +version = "2.1.3" description = "A Python package for early warning signals (EWS) of bifurcations in time series data." authors = [{ name = "Thomas M Bury", email = "tombury182@gmail.com" }] readme = "README.md" requires-python = ">=3.9" dependencies = [ "pandas>=0.23.0", - "numpy>=1.26.0,<2.0", # 1.26 is oldest with 3.12 wheels; <2.0 avoids breaking changes + "numpy>=1.26.0", # 1.26 is oldest with 3.12 wheels. NumPy 2.x supported and covered by CI. "plotly>=2.3.0", "lmfit>=0.9.0", "arch>=6.2", # 6.2 is oldest with compiled 3.12 wheels diff --git a/tests/test_ewstools.py b/tests/test_ewstools.py index 288f47f..5750c7e 100644 --- a/tests/test_ewstools.py +++ b/tests/test_ewstools.py @@ -553,3 +553,22 @@ def test_mean_ci(): assert type(intervals) == dict assert type(intervals["Mean"]) == np.float64 or type(intervals["Mean"]) == float + + +def test_kolmogorov_entropy_works_on_numpy_2(): + """EntropyHub 2.0 uses np.NaN, removed in NumPy 2.0, so EH.K2En() raised + AttributeError on a modern stack. ewstools 2.1.3 relaxes the NumPy pin to + allow 2.x, which would have shipped a release where compute_entropy( + method='sample') worked and method='kolmogorov' broke -- a partial failure + that stays hidden until someone uses that method. Regression test for the + compatibility shim in ewstools/core.py.""" + import numpy as np + import ewstools + from ewstools.core import TimeSeries + from ewstools.models import simulate_ricker + + series = simulate_ricker(tmax=200, F=[0, 2.7]) + ts = TimeSeries(data=series, transition=180) + ts.detrend(method="Lowess", span=0.2) + ts.compute_entropy(rolling_window=0.5, method="kolmogorov") + assert any("kolmogorov" in c for c in ts.ews.columns)