From cd54b26034f5cc6da506326dcc23bfe425ba791b Mon Sep 17 00:00:00 2001 From: energyscholar Date: Tue, 21 Jul 2026 14:14:14 -0700 Subject: [PATCH 1/4] Release 2.1.3: ship the spectral SciPy/pandas fix, allow NumPy 2.x, test 3.13/3.14 Fixes #474. compute_spectrum() raised KeyError 'key of type tuple not found and not a MultiIndex' on modern 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. The np.asarray() boundary fix has been on main since March but was never published, so every PyPI install still broke. Removes the numpy<2.0 ceiling -- NumPy 2.x works and is now under CI. The pin was what pushed users onto untested combinations. Dependency floors left alone deliberately: numpy>=1.26 already forces every other dep to be modern, so the stale 2018-era floors are inert, and changing them enlarges the diff for no user benefit. Extends CI to Python 3.13/3.14 with fail-fast:false -- nothing was testing a current stack, which is why a fix that worked was indistinguishable from a fix that had not shipped. Verified in clean containers with deps resolved from pyproject: py3.9.25 numpy 2.0.2 pandas 2.3.3 scipy 1.13.1 -> 30 passed 1 skipped, 6/6 spectral py3.12.13 numpy 2.5.1 pandas 3.0.3 scipy 1.18.0 -> 30 passed 1 skipped, 6/6 spectral py3.14.6 numpy 2.5.1 pandas 3.0.3 scipy 1.18.0 -> 30 passed 1 skipped, 6/6 spectral No API or behavioural changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/tests.yml | 11 +++++--- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 4 +-- 3 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.md 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/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 From 6c55c71b27307bc72249059ee7a74b35bddb2183 Mon Sep 17 00:00:00 2001 From: energyscholar Date: Tue, 28 Jul 2026 12:22:48 -0700 Subject: [PATCH 2/4] Add trusted-publishing release workflow Publishes to PyPI on a v* tag using 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 elsewhere. Guards the classic release failure: if the tag disagrees with the version in pyproject.toml the build fails loudly before publishing, rather than shipping under the wrong number or being rejected as a duplicate. Requires a one-time registration by a PyPI project Owner; the five values are in the file header. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9c25f69 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release to PyPI + +# Publishes to PyPI when a version tag is pushed. +# +# Uses PyPI Trusted Publishing (OIDC) — there is NO API token stored anywhere. +# GitHub mints a short-lived identity token for this specific workflow in this +# specific repository, and PyPI verifies it. Nothing long-lived exists to leak, +# and a stolen credential cannot be replayed from anywhere else. +# +# One-time setup on PyPI (project Owner only — pypi.org/manage/project/ewstools/settings/publishing/): +# PyPI project name : ewstools +# Owner : ThomasMBury +# Repository name : ewstools +# Workflow filename : release.yml +# Environment name : pypi +# +# To cut a release: +# 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. + +on: + push: + tags: + - 'v*' + workflow_dispatch: # manual re-run without re-tagging + +permissions: + contents: read + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + # The classic release failure is a tag that disagrees with the packaged + # version: you tag v2.1.3, ship 2.1.2, and PyPI rejects it as a duplicate + # (or worse, accepts it under the wrong number). Fail here, 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 + + - name: Check metadata renders on PyPI + run: python -m twine check dist/* + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # Gate the credential on a named environment so the publish step can be + # given required reviewers later if you ever want a human approval on + # release, without touching 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/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 From c2c297a5bcc1e9ec29d9cbe8be793e76196d3f5d Mon Sep 17 00:00:00 2001 From: energyscholar Date: Tue, 28 Jul 2026 12:24:23 -0700 Subject: [PATCH 3/4] Release workflow: build on PRs, dry-run to TestPyPI, publish only on tags Verified every trigger path resolves correctly: tag -> PyPI; PR, push-to-main and the default manual dispatch -> build only; dispatch must explicitly name testpypi or pypi to publish anywhere. No event can publish by accident. Adds beyond the first draft: - builds on every PR, so a broken build or bad metadata surfaces at review time rather than at release time - twine check --strict, plus an install-and-import smoke test of the actual wheel - a TestPyPI dry-run target, so the whole path can be proven before touching real PyPI - concurrency guard against a double-pushed tag racing two uploads Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 102 ++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c25f69..f6542eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,47 +1,67 @@ -name: Release to PyPI +name: Release -# Publishes to PyPI when a version tag is pushed. +# Builds on every PR; publishes only on a version tag. # -# Uses PyPI Trusted Publishing (OIDC) — there is NO API token stored anywhere. -# GitHub mints a short-lived identity token for this specific workflow in this -# specific repository, and PyPI verifies it. Nothing long-lived exists to leak, -# and a stolen credential cannot be replayed from anywhere else. +# 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 on PyPI (project Owner only — pypi.org/manage/project/ewstools/settings/publishing/): -# PyPI project name : ewstools +# ── 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 # -# To cut a release: -# 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. +# 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*' - workflow_dispatch: # manual re-run without re-tagging + 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 distributions + name: Build and check distributions runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.12' + 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 rejects it as a duplicate - # (or worse, accepts it under the wrong number). Fail here, before build. + # 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: | @@ -58,21 +78,47 @@ jobs: 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 dist/* + 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: + 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 - # Gate the credential on a named environment so the publish step can be - # given required reviewers later if you ever want a human approval on - # release, without touching this file. + # 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 @@ -80,9 +126,5 @@ jobs: id-token: write # REQUIRED for trusted publishing; nothing else is steps: - uses: actions/download-artifact@v4 - with: - name: dist - path: dist/ - - - name: Publish - uses: pypa/gh-action-pypi-publish@release/v1 + with: {name: dist, path: dist/} + - uses: pypa/gh-action-pypi-publish@release/v1 From 88e7f3849e9f38da76e543b95d150fccb543919a Mon Sep 17 00:00:00 2001 From: energyscholar Date: Tue, 28 Jul 2026 12:45:16 -0700 Subject: [PATCH 4/4] Fix compute_entropy on NumPy 2: EntropyHub still uses the removed np.NaN Found by executing the tutorials against the modern stack this PR enables. This PR relaxes the pin to allow NumPy 2.x. EntropyHub 2.0 -- the LATEST release, no fixed version exists -- still uses np.NaN in 4 files, removed in NumPy 2.0. So as it stood, 2.1.3 would have shipped a release where compute_entropy(method='sample') worked and method='kolmogorov' raised AttributeError. A partial break, which is worse than a total one: it stays hidden until someone uses that method. ewstools' own source is clean; the alias is restored immediately before the EntropyHub import. np.NaN was only ever a spelling of np.nan, so this is not a behaviour change. Verified on numpy 2.4.6 / pandas 3.0.5 / scipy 1.17.1: kolmogorov fails without the shim and passes with it; full suite 31 passed, 1 skipped. Should be removed once EntropyHub ships a NumPy 2 compatible release. Co-Authored-By: Claude Opus 5 (1M context) --- ewstools/core.py | 8 ++++++++ tests/test_ewstools.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) 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/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)