Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 8 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions ewstools/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tests/test_ewstools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading