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
22 changes: 11 additions & 11 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ jobs:
python -c "import matplotlib.font_manager as fm; fm._load_fontmanager(try_read_cache=False)"

- name: Run pytest
# Ubuntu / Python 3.12 runs the suite ONCE, in the pandas-3 + coverage
# step below. It used to run it three times in a row (here, then the
# pandas-3 gate, then coverage: 36 + 36 + 50 minutes), which made that
# one job, and so every CI run, take two hours.
if: ${{ !(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12') }}
env:
MPLBACKEND: Agg
# Authenticate the fivethirtyeight loader's GitHub API listing calls
Expand All @@ -156,24 +161,19 @@ jobs:
run: |
pytest -v --tb=short

- name: Acceptance gate — pandas 3.0 (Ubuntu Python 3.12 only)
# Guarantees a pandas-3 run regardless of resolver choices (dw#30/#31 fix).
- name: Run pytest — pandas 3.0 acceptance gate, with coverage (Ubuntu Python 3.12 only)
# This matrix cell's one test run. It guarantees a pandas-3 run
# regardless of resolver choices (dw#30/#31 fix) and produces the
# coverage report in the same pass.
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
env:
MPLBACKEND: Agg
# see "Run pytest" above for why the token is passed
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pip install "pandas>=3.0"
python -c "import pandas as pd; assert pd.__version__.split('.')[0] == '3', pd.__version__; print('pandas', pd.__version__)"
pytest -q

- name: Run pytest with coverage (Ubuntu Python 3.12 only)
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
env:
MPLBACKEND: Agg
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
pytest --cov=hypertools --cov-report=xml --cov-report=term-missing
pytest -v --tb=short --cov=hypertools --cov-report=xml --cov-report=term-missing

- name: Upload coverage to Codecov (Ubuntu Python 3.12 only)
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
Expand Down
15 changes: 11 additions & 4 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,12 @@ detached tag checkout — the notebook migrator detects the branch via
`python scripts/publish_gallery_notebooks.py --ref v1.1.0 --notebooks-dir docs/auto_examples --push`
(the `docs-notebooks` branch already exists; the script deletes and
rewrites the whole `v1.1.0/` namespace and its `manifest.json`, so a
re-cut republishes cleanly over the draft's publish). Publishing static
notebooks before PyPI is harmless: their `%pip install hypertools[...]`
cells resolve 1.1 the moment PyPI is updated (step 6).
re-cut republishes cleanly over the draft's publish). The notebooks'
install cells carry a version floor (`hypertools[...]>=1.1.0`), so a
runtime opened before the PyPI upload (step 6) fails to install rather
than quietly keeping 1.0.0; do not test them on Colab until PyPI has
1.1.0, and after the upload run one gallery notebook and one tutorial
on Colab in a fresh runtime.
Both the `master` "latest" docs and the `v1.1.0` "stable" docs resolve to
this ONE `v1.1.0` namespace (`docs/post_build.py` `_publish_ref`), so this
single publish covers both.
Expand Down Expand Up @@ -212,7 +215,11 @@ same artifacts you verify are the ones you publish.
`git push --force origin refs/tags/v1.1.0`. A moved tag is safe ONLY
because nothing has been published from the old one (PyPI still has
1.0.0, the GitHub release is a draft); once PyPI has 1.1.0 the tag is
frozen.
frozen for anything that changes the package. (On 2026-10-10, after the
PyPI upload, the tag was moved once more for a docs-only fix to the
gallery notebooks, by the maintainer's decision: the package code is
identical, so the PyPI files still match the tag's `hypertools/` tree,
but they were built from the earlier commit `cf76a931`.)
- [ ] Confirm: `git ls-remote origin refs/tags/v1.1.0^{}` == the new sha.
- [ ] Wait for the `v1.1.0` tag CI to go GREEN (the workflow's `tags: ['v*']`
trigger runs the same jobs; `release-gate` + `docs-clean` gallery scan
Expand Down
62 changes: 62 additions & 0 deletions docs/_gallery_notebooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Post-processing for the notebooks sphinx-gallery generates.

Kept in its own SIDE-EFFECT-FREE module (standard library only) so
tests/test_gallery_notebook_playback.py can unit-test it.

Six animated showcase examples build their animation inside
``if __name__ == '__main__':`` and end with ``fig = anim.figure``, so that
importing the example (the test suite does) builds nothing. The docs page
still shows the animation, because the docs build captures it. A notebook
shows nothing: an assignment inside an ``if`` block is not a cell value.
``add_inline_playback`` appends a final cell holding ``anim``, which Jupyter
and Colab render as an inline video through ``HyperAnimation._repr_html_``.
"""
import copy
import json
import re

PLAYBACK_SOURCE = (
'# Play the animation inline. The whole clip is rendered first, which\n'
'# takes from a few seconds to a few minutes, depending on its length.\n'
'anim')
_ENDS_WITH_UNSHOWN_ANIMATION = re.compile(r'(?m)^[ \t]+fig = anim\.figure[ \t]*$')


def needs_inline_playback(nb):
"""True when the notebook's last code cell ends by binding
``fig = anim.figure`` inside a block, i.e. it built an animation and
displayed nothing."""
code = [c for c in nb.get('cells', []) if c.get('cell_type') == 'code']
if not code:
return False
source = code[-1]['source']
source = ''.join(source) if isinstance(source, list) else source
lines = [ln for ln in source.splitlines() if ln.strip()]
return bool(lines) and bool(_ENDS_WITH_UNSHOWN_ANIMATION.match(lines[-1]))


def add_inline_playback(path):
"""Append the playback cell to the notebook at ``path`` if it needs one.
Returns True when the file was changed. Idempotent: once the cell is
there, the last code cell no longer ends with the assignment."""
with open(path, encoding='utf-8') as f:
raw = f.read()
nb = json.loads(raw)
if not needs_inline_playback(nb):
return False
template = [c for c in nb['cells'] if c['cell_type'] == 'code'][-1]
cell = copy.deepcopy(template)
cell['outputs'] = []
cell['execution_count'] = None
cell['source'] = PLAYBACK_SOURCE.splitlines(keepends=True)
nb['cells'].append(cell)
# keep the file's own JSON layout so the change is one appended cell
tail = '\n' if raw.endswith('\n') else ''
original = json.loads(raw)
layout = next(((i, a) for i in (1, 2, None) for a in (False, True)
if json.dumps(original, indent=i, ensure_ascii=a) + tail
== raw), (1, False))
with open(path, 'w', encoding='utf-8') as f:
f.write(json.dumps(nb, indent=layout[0], ensure_ascii=layout[1])
+ tail)
return True
21 changes: 20 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ def _install_notebook_cell():
# master or a vX.Y.Z release tag -> the released package; anything else
# (dev-1.0, feature branches) -> that branch from GitHub as a preview.
if branch == 'master' or re.fullmatch(r'v\d+\.\d+\.\d+', branch):
pip = '%pip install -q "hypertools[interactive]"'
# the floor matters: without it, a runtime that already has an older
# hypertools (a Colab session opened before this release reached
# PyPI, or any environment with the previous release) reports the
# requirement satisfied and the examples run against the old version
import hypertools
pip = ('%pip install -q "hypertools[interactive]>='
f'{hypertools.__version__}"')
note = '# Install hypertools (run this first on Colab)'
else:
url = 'git+https://github.com/ContextLab/hypertools.git@' + branch
Expand Down Expand Up @@ -570,6 +576,16 @@ def _gallery_page_context(app, pagename, templatename, context, doctree):
context['theme_source_view_link'] = _GALLERY_VIEW_LINK


def _add_inline_playback_cells(app):
"""Give the animated showcase notebooks a final cell that plays the
animation (docs/_gallery_notebooks.py says why they need one)."""
import glob
from _gallery_notebooks import add_inline_playback
for path in sorted(glob.glob(os.path.join(app.srcdir, 'auto_examples',
'*.ipynb'))):
add_inline_playback(path)


def setup(app):
app.connect('html-page-context', _gallery_page_context)
# Keep the strict (-W) docs-clean CI gate robust to TRANSIENT third-party
Expand All @@ -581,3 +597,6 @@ def setup(app):
# docs/_gallery_log_filter.py and tests/test_docs_gallery_log_filter.py.
from _gallery_log_filter import install
install()
# sphinx-gallery writes the notebooks in its own 'builder-inited'
# handler (default priority 500); run after it
app.connect('builder-inited', _add_inline_playback_cells, priority=900)
4 changes: 2 additions & 2 deletions docs/prebuilt_gallery.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"commit": "6a194eab1a2a9210ee653481863f8874d6d3b501",
"source_commit": "391637673efb7d4450d4cf907c69c5b76c3730af",
"commit": "519b4af2dd63c3f32b120696d8afb403d8e06068",
"source_commit": "2765df086b3491bdff3b1ba47e3148fb6ac1883a",
"version": "1.1.0"
}
32 changes: 32 additions & 0 deletions notes/session_2026-10-08_visual-signoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,35 @@ Branch `fix/1.1-release-review`; PR #286 still OPEN and unmerged. Nothing merged
- Parallel gallery trial (6 workers, scratch clone, while the serial build ran): 12.9 min, exit 0 with -W.
animate_weather_decades (696 s) and animate_market_sectors (573 s) bound it. One loky "worker stopped"
notice. Not adopted for 1.1.0.

## 2026-10-10: release day and the first post-release report

- 12:10 UTC: `v1.1.0` tag moved to cf76a931 (master CI 17/17 green at 08:15; four idle hours because my
waiter watched the "Dependency Graph" run id instead of the "Tests" run). Tag CI 17/17 green 14:11.
Each CI run takes ~2 h because the ubuntu-3.12 job runs the suite three times serially.
- Jeremy: "Can you do steps 1--3, and also do the conda forge release? I'll post the bluesky thread".
RTD `stable` re-synced by redelivering the last master push webhook delivery (RTD ignores a force-moved
tag); PyPI 1.1.0 uploaded 14:40 (digests match; clean-env wheel smoke OK); GitHub release published
14:42; conda-forge/hypertools-feedstock#1 opened from the jeremymanning fork, rerendered, merged 14:51,
package uploaded (CDN index lag; clean conda install not yet confirmed).
- First report after release (Jeremy, Colab, gallery notebook animate_morph_zoo): (1) TypeError
"Normalize.__init__() got an unexpected keyword argument 'mode'": a runtime opened before the PyPI
upload had hypertools 1.0.0 and the unfloored `%pip install -q "hypertools[interactive]"` was "already
satisfied". Deleting the runtime fixed it. (2) the notebook showed no figure: six showcase examples end
with `fig = anim.figure` inside `if __name__ == '__main__':`, which displays nothing in a notebook.
- Fixed in the PUBLISHED notebooks (docs-notebooks branch, no change to master or the tag): all 51 install
cells now read `hypertools[interactive]>=1.1.0` (Jeremy: keep it); the six showcases have a final cell
`anim` (Jeremy chose "Play the animation inline"). Verified on Colab: morph zoo plays a 30 s video,
rendered in 48 s. Measured inline render with the PyPI package, locally: morph 4 s, forecast 9 s,
conversation 19 s, paintings 52 s, market 74 s, weather 235 s; none exceeds the embed limit.
- Source fix for the next release is on branch `fix/gallery-install-floor` (NOT master: the release gate
allows no commit there between releases): conf.py emits the floored install line and appends the
playback cell after sphinx-gallery writes the notebooks (docs/_gallery_notebooks.py);
check_release_notebooks.py rejects an unfloored install; tests added. What we missed: no gallery
notebook was run on Colab against the published package; the checklist now says to.
- Jeremy: "can we move the tag forward so the notebook links work? it's OK for tutorials to be out of
sync with pypi, since the code itself (of the package) is unchanged". Plan: fast-forward master to
this branch, cold gallery build, rehearsal, publish + record, notebooks, gates, push master and move
the tag together, re-sync RTD stable. No new PyPI upload; dist/ untouched.
- Batched into the same commit: the ubuntu-3.12 CI job now runs the suite once (pandas-3 gate + coverage
in one pass) instead of three times; expected run length ~50 min instead of ~2 h.
16 changes: 14 additions & 2 deletions scripts/add_colab_install_cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Gallery install lines are branch-aware:

* on ``master`` OR a ``vX.Y.Z`` release tag it installs the RELEASED package
(``%pip install -q "hypertools[interactive]"``);
(``%pip install -q "hypertools[interactive]>=<version>"``);
* on any other branch it installs THAT branch from GitHub, so the dev-1.0
preview notebooks install the matching dev build rather than the older
PyPI release.
Expand Down Expand Up @@ -121,9 +121,21 @@ def portable_video_source(filename):
return '# Colab serves output frames separately from kernel files; embed movie bytes.\ntry:\n from google import colab as colab\nexcept ImportError:\n pass # Local Jupyter/Sphinx uses the relative video below.\nelse:\n from IPython.display import Video, display\n display(Video({filename!r}, embed=True))\n'.format(filename=filename)


def _project_version():
"""The release version, from pyproject.toml (the gallery install cell's
floor; docs/conf.py uses ``hypertools.__version__``, the same number)."""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(root, 'pyproject.toml'), encoding='utf-8') as f:
return re.search(r'(?m)^version\s*=\s*["\']([^"\']+)["\']',
f.read()).group(1)


def install_lines(branch):
if _is_release_ref(branch):
pip = '%pip install -q "hypertools[interactive]"'
# floored: an environment that already has an older hypertools would
# otherwise satisfy the requirement and run the examples against it
pip = ('%pip install -q "hypertools[interactive]>='
f'{_project_version()}"')
note = '# Install hypertools (run this first on Colab)'
else:
pip = f'%pip install -q "{hyp_spec("interactive", branch)}"'
Expand Down
25 changes: 24 additions & 1 deletion scripts/check_release_notebooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ def classify_notebooks(paths):
return missing, branch_installs, stale_notes


_FLOORED_RE = re.compile(r'hypertools\[[^\]]*\]\s*>=\s*\d')


def unfloored_installs(paths):
"""Notebooks with a hypertools install line that carries no version
floor (``hypertools[...]>=X.Y.Z``). Without one, ``pip install`` in an
environment that already has an OLDER hypertools reports the requirement
satisfied, and a notebook written for this release runs against the old
one (seen on Colab: a session opened before 1.1.0 reached PyPI kept 1.0.0
and the 1.1 examples failed with a TypeError)."""
names = []
for p in paths:
with open(p, encoding='utf-8') as f:
nb = json.load(f)
if any(not _FLOORED_RE.search(ln) for ln, _ in _hyp_install_lines(nb)):
names.append(os.path.basename(p))
return names


def _collect(dirs_or_globs):
paths = []
for d in dirs_or_globs:
Expand Down Expand Up @@ -122,7 +141,8 @@ def main(argv=None):
file=sys.stderr)
return 1
missing, branch, stale = classify_notebooks(paths)
if not (missing or branch or stale):
unfloored = unfloored_installs(paths)
if not (missing or branch or stale or unfloored):
print(f'release notebook check OK: {len(paths)} notebooks, '
'all install the PyPI package')
return 0
Expand All @@ -132,6 +152,9 @@ def main(argv=None):
print(f'FAIL git+/@branch install: {branch}', file=sys.stderr)
if stale:
print(f'FAIL leftover preview note: {stale}', file=sys.stderr)
if unfloored:
print(f'FAIL hypertools install without a version floor (>=X.Y.Z): '
f'{unfloored}', file=sys.stderr)
return 1


Expand Down
Loading
Loading