Skip to content

Commit 2fc566b

Browse files
kip-hartclaude
andcommitted
Restore the example figures on Read the Docs instead of rebuilding them
A build on Read the Docs is stopped at fifteen minutes on the free plan. The documentation build runs every example in src/microstructpy/examples through docs/source/sphinx_gallery/plot_demos.py, which takes about twenty minutes on that class of hardware, so the build cannot finish. The examples produce the same figures every time, so this moves the work to the documentation workflow, which has no such limit, and has Read the Docs restore what it produced. Sphinx-Gallery already skips a script whose hash matches the .md5 beside the output of an earlier build. The difficulty is that plot_demos.py finds the examples with glob and names none of them, so its hash does not move when an example is edited or added, nor when the library that meshes them changes. Caching on that hash alone would serve stale figures and never report it. docs/example_digest.py closes that gap. It digests the example inputs and the library source and keeps the result in plot_demos.py as EXAMPLES_DIGEST, so editing any of them changes the hash of the script and the examples run again. The documentation check runs it with --check and fails if the stored digest is out of date. docs/example_cache.py packs and restores the Sphinx-Gallery output and the PNG files the examples write, which the pages embed with figure::. An archive carries the digest it was built from and restore refuses one that does not match the working tree, so a build that cannot be warm runs the examples and may time out rather than publish stale figures. Measured locally, with the examples run on the same machine: cold build 655.4 s warm build 6.7 s The documentation workflow publishes an archive from its html job to the docs-cache release, and asks Read the Docs to build again so the archive is picked up. That rebuild needs a READTHEDOCS_TOKEN secret. Without it the step says so and does nothing, and the build has to be restarted by hand once the archive is published. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fd592e6 commit 2fc566b

6 files changed

Lines changed: 493 additions & 3 deletions

File tree

‎.github/workflows/documentation.yaml‎

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ name: Documentation check
33

44
on: [pull_request]
55

6-
jobs:
6+
permissions:
7+
contents: write
8+
9+
jobs:
710
docs-checks:
811
name: ${{ matrix.doc-type }}
912
strategy:
1013
matrix:
1114
doc-type: [html, latex, epub]
12-
15+
1316
runs-on: ubuntu-latest
1417
steps:
1518
- name: Checkout
@@ -28,9 +31,62 @@ jobs:
2831
run: |
2932
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
3033
pip install -e .
34+
- name: Check the example digest is current
35+
run: python docs/example_digest.py --check
36+
3137
- name: Build ${{ matrix.doc-type }} documentation
3238
run: sphinx-build -Wnb ${{ matrix.doc-type }} docs/source/ docs/build-${{ matrix.doc-type }}/
33-
39+
40+
# Read the Docs stops a build at fifteen minutes and the examples take
41+
# about twenty, so it restores what the examples produced here instead
42+
# of running them. The archive is named by the digest of the example
43+
# inputs and of the library, and Read the Docs only restores one whose
44+
# digest matches the commit it is building.
45+
- name: Publish the example cache
46+
if: matrix.doc-type == 'html' && github.event.pull_request.head.repo.full_name == github.repository
47+
env:
48+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+
run: |
50+
name=$(python docs/example_cache.py name)
51+
python docs/example_cache.py pack "$name"
52+
gh release view docs-cache >/dev/null 2>&1 \
53+
|| gh release create docs-cache --title "Documentation example cache" \
54+
--notes "Figures the documentation examples produce, named by the digest from docs/example_digest.py. Read the Docs restores the archive matching the commit it builds. Managed by the documentation workflow." \
55+
--latest=false
56+
gh release upload docs-cache "$name" --clobber
57+
58+
# A pull request that changes an example has no cache for its new
59+
# digest until the step above finishes, so the Read the Docs build
60+
# that started alongside this job ran the examples and timed out.
61+
# Build it again now that the archive is published, and it is warm.
62+
#
63+
# Needs a READTHEDOCS_TOKEN secret, from Settings -> API Tokens on
64+
# Read the Docs. Without it this step says so and does nothing, and
65+
# the only cost is having to press Rebuild by hand.
66+
- name: Rebuild the docs on Read the Docs
67+
if: matrix.doc-type == 'html' && github.event.pull_request.head.repo.full_name == github.repository
68+
env:
69+
RTD_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
70+
RTD_VERSION: ${{ github.event.pull_request.number }}
71+
run: |
72+
if [ -z "$RTD_TOKEN" ]; then
73+
echo "No READTHEDOCS_TOKEN secret, skipping the rebuild."
74+
echo "Press Rebuild on the Read the Docs build to pick up the cache."
75+
exit 0
76+
fi
77+
# A pull request build is a version named after the pull request.
78+
code=$(curl -sS -o /tmp/rtd.json -w '%{http_code}' -X POST \
79+
-H "Authorization: Token $RTD_TOKEN" \
80+
"https://app.readthedocs.org/api/v3/projects/microstructpy/versions/$RTD_VERSION/builds/")
81+
echo "Read the Docs answered $code"
82+
cat /tmp/rtd.json
83+
case "$code" in
84+
202) echo "Rebuild triggered." ;;
85+
*) echo "Could not trigger a rebuild. The cache is published, so a"
86+
echo "rebuild by hand will be warm."; exit 0 ;;
87+
esac
88+
89+
3490
- name: Prepare documentation artifact
3591
run: |
3692
# Define Path to Upload

‎.readthedocs.yaml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,25 @@ build:
1313
python: "3.10"
1414
apt_packages:
1515
- freeglut3-dev
16+
jobs:
17+
# Running every example takes about twenty minutes, and a build here
18+
# is stopped at fifteen. The documentation workflow runs them on a
19+
# machine without that limit and publishes what they produced, keyed
20+
# by a digest of the example inputs and of the library. Restore that
21+
# and Sphinx-Gallery skips the examples.
22+
#
23+
# The archive is only restored when its digest is the digest of this
24+
# commit, so an edited example is never served with older figures.
25+
# When there is no archive to match, nothing is restored, the
26+
# examples run, and the build is likely to time out. That is the
27+
# intended failure: publish the cache for this commit, then rebuild.
28+
post_install:
29+
- python docs/example_cache.py name > /tmp/cache_name
30+
- |
31+
curl -sSfL -o /tmp/examples.tar.gz \
32+
"https://github.com/kip-hart/MicroStructPy/releases/download/docs-cache/$(cat /tmp/cache_name)" \
33+
&& python docs/example_cache.py restore /tmp/examples.tar.gz \
34+
|| echo "No example cache for this commit, the examples will be run."
1635
1736
# Build documentation in the "docs/" directory with Sphinx
1837
sphinx:

‎docs/README.rst‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
Building the documentation
2+
==========================
3+
4+
::
5+
6+
pip install -r docs/requirements.txt
7+
pip install -r requirements.txt
8+
pip install -e .
9+
sphinx-build -Wnb html docs/source docs/build-html
10+
11+
The first build takes about twenty minutes, because
12+
``docs/source/sphinx_gallery/plot_demos.py`` runs every example in
13+
``src/microstructpy/examples`` to produce the figures the pages embed.
14+
Later builds reuse that work and take seconds.
15+
16+
17+
The example cache
18+
-----------------
19+
20+
A build on Read the Docs is stopped at fifteen minutes on the free plan,
21+
which is less than the examples take. The documentation workflow runs
22+
them on a machine without that limit and publishes what they produced,
23+
and the Read the Docs build restores it instead of running them.
24+
25+
Two pieces make that safe.
26+
27+
``docs/example_digest.py`` digests the example inputs and the library
28+
source, and keeps the result in ``plot_demos.py`` as ``EXAMPLES_DIGEST``.
29+
Sphinx-Gallery decides whether to run a script by hashing it, and
30+
``plot_demos.py`` finds the examples with ``glob`` rather than naming
31+
them, so without this its hash would not move when an example was edited
32+
or added, and a cached build would serve stale figures. Editing an
33+
example now changes the digest, which changes the hash, which runs the
34+
examples again.
35+
36+
``docs/example_cache.py`` packs and restores the figures. An archive
37+
carries the digest it was built from, and ``restore`` refuses one that
38+
does not match the working tree. A build that cannot be warm runs the
39+
examples and may time out, which is intended: it is better to be slow and
40+
correct than fast and stale.
41+
42+
After editing an example or anything in ``src/microstructpy``::
43+
44+
python docs/example_digest.py --write
45+
46+
The documentation check runs ``python docs/example_digest.py --check`` and
47+
fails if the stored digest is out of date.
48+
49+
50+
Publishing a cache by hand
51+
--------------------------
52+
53+
The workflow does this on every pull request, from the ``html`` job. To do
54+
it manually, after a full build::
55+
56+
name=$(python docs/example_cache.py name)
57+
python docs/example_cache.py pack "$name"
58+
gh release upload docs-cache "$name" --clobber
59+
60+
Read the Docs fetches the archive for the digest of the commit it is
61+
building, so a commit whose examples and library are unchanged reuses the
62+
archive of an earlier one.
63+
64+
A pull request that changes an example has no cache for its new digest
65+
until its ``html`` job finishes. A Read the Docs build that starts before
66+
that will run the examples and time out. Rebuild it once the job has
67+
published the archive.

‎docs/example_cache.py‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#!/usr/bin/env python3
2+
"""Pack and restore the figures the documentation examples produce.
3+
4+
The documentation build runs every example in
5+
``src/microstructpy/examples``, which takes about twenty minutes and does
6+
not fit in the fifteen minute limit of a Read the Docs build on the free
7+
plan. The work is the same on every build, so this module moves it to a
8+
machine without a time limit: the documentation workflow packs what the
9+
examples produced, and the Read the Docs build restores it before Sphinx
10+
runs.
11+
12+
Two sets of files are needed, and both are in the archive:
13+
14+
* ``docs/source/auto_examples``, the output of Sphinx-Gallery. It holds
15+
the ``.md5`` of ``plot_demos.py``, which is what makes Sphinx-Gallery
16+
skip the examples instead of running them.
17+
* The PNG files under ``src/microstructpy/examples``, which the pages in
18+
``docs/source/examples`` embed with ``figure::``. The meshes beside
19+
them are not in the archive, since no page refers to them.
20+
21+
An archive carries the digest of the inputs it was built from, from
22+
:mod:`docs.example_digest`. ``restore`` refuses an archive whose digest
23+
is not the digest of the working tree, so an edited example cannot be
24+
served with the figures of an older one. Refusing leaves the build to
25+
run the examples itself, which is slow and may time out, and that is the
26+
intended outcome: a build that cannot be warm should be loud, not stale.
27+
28+
Usage::
29+
30+
python docs/example_cache.py pack examples.tar.gz
31+
python docs/example_cache.py restore examples.tar.gz
32+
python docs/example_cache.py name # archive name for this tree
33+
34+
"""
35+
36+
import argparse
37+
import json
38+
import os
39+
import sys
40+
import tarfile
41+
42+
HERE = os.path.dirname(os.path.abspath(__file__))
43+
ROOT = os.path.dirname(HERE)
44+
45+
sys.path.insert(0, HERE)
46+
from example_digest import digest # noqa: E402
47+
48+
GALLERY = os.path.join('docs', 'source', 'auto_examples')
49+
EXAMPLES = os.path.join('src', 'microstructpy', 'examples')
50+
MANIFEST = 'microstructpy-example-cache.json'
51+
52+
53+
def _gallery_members():
54+
"""Every file of the Sphinx-Gallery output, relative to the root."""
55+
members = []
56+
root = os.path.join(ROOT, GALLERY)
57+
for dirpath, dirnames, filenames in os.walk(root):
58+
dirnames.sort()
59+
for name in sorted(filenames):
60+
path = os.path.join(dirpath, name)
61+
members.append(os.path.relpath(path, ROOT))
62+
return members
63+
64+
65+
def _figure_members():
66+
"""Every PNG an example wrote, relative to the root.
67+
68+
Only the files in the output directories of the examples, so that an
69+
input such as ``aluminum_micro.png`` is not carried in the archive.
70+
"""
71+
members = []
72+
root = os.path.join(ROOT, EXAMPLES)
73+
for dirpath, dirnames, filenames in os.walk(root):
74+
dirnames.sort()
75+
if os.path.abspath(dirpath) == os.path.abspath(root):
76+
continue # the inputs live here, the figures are in subdirs
77+
for name in sorted(filenames):
78+
if name.endswith('.png'):
79+
path = os.path.join(dirpath, name)
80+
members.append(os.path.relpath(path, ROOT))
81+
return members
82+
83+
84+
def pack(filename):
85+
"""Write an archive of the figures and the gallery output."""
86+
gallery = _gallery_members()
87+
figures = _figure_members()
88+
89+
if not gallery:
90+
print('No %s to pack. Build the documentation first.' % GALLERY,
91+
file=sys.stderr)
92+
return 1
93+
if not figures:
94+
print('No figures under %s to pack.' % EXAMPLES, file=sys.stderr)
95+
return 1
96+
97+
value = digest()
98+
manifest = json.dumps({'digest': value,
99+
'gallery': len(gallery),
100+
'figures': len(figures)}, indent=2).encode()
101+
102+
with tarfile.open(filename, 'w:gz') as tar:
103+
info = tarfile.TarInfo(MANIFEST)
104+
info.size = len(manifest)
105+
tar.addfile(info, _BytesIO(manifest))
106+
for rel in gallery + figures:
107+
tar.add(os.path.join(ROOT, rel), arcname=rel)
108+
109+
size = os.path.getsize(filename) / 1048576.0
110+
print('packed %d gallery files and %d figures for digest %s '
111+
'into %s (%.1f MB)'
112+
% (len(gallery), len(figures), value, filename, size))
113+
return 0
114+
115+
116+
def restore(filename):
117+
"""Extract an archive, if its digest is the digest of this tree."""
118+
if not os.path.exists(filename):
119+
print('No archive at %s.' % filename, file=sys.stderr)
120+
return 1
121+
122+
with tarfile.open(filename, 'r:gz') as tar:
123+
try:
124+
manifest = json.loads(tar.extractfile(MANIFEST).read().decode())
125+
except KeyError:
126+
print('%s has no %s, refusing to restore it.'
127+
% (filename, MANIFEST), file=sys.stderr)
128+
return 1
129+
130+
current = digest()
131+
if manifest.get('digest') != current:
132+
print('The archive was built from %s and the examples digest '
133+
'to %s. Refusing to restore it, the examples will be run.'
134+
% (manifest.get('digest'), current), file=sys.stderr)
135+
return 1
136+
137+
members = [m for m in tar.getmembers() if m.name != MANIFEST]
138+
for m in members:
139+
if m.name.startswith('/') or '..' in m.name.split('/'):
140+
print('Refusing a path outside the tree: %s' % m.name,
141+
file=sys.stderr)
142+
return 1
143+
try:
144+
tar.extractall(ROOT, members=members, filter='data')
145+
except TypeError:
146+
# filter= arrived in Python 3.12. The paths are checked above.
147+
tar.extractall(ROOT, members=members)
148+
149+
print('restored %d files for digest %s' % (len(members), current))
150+
return 0
151+
152+
153+
def name():
154+
"""Print the archive name for this working tree."""
155+
print('microstructpy-examples-%s.tar.gz' % digest())
156+
return 0
157+
158+
159+
class _BytesIO(object):
160+
"""A minimal file object, so tarfile can add bytes already in memory."""
161+
162+
def __init__(self, data):
163+
self._data = data
164+
self._pos = 0
165+
166+
def read(self, size=-1):
167+
if size < 0:
168+
chunk = self._data[self._pos:]
169+
else:
170+
chunk = self._data[self._pos:self._pos + size]
171+
self._pos += len(chunk)
172+
return chunk
173+
174+
175+
def main():
176+
parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
177+
sub = parser.add_subparsers(dest='command', required=True)
178+
p = sub.add_parser('pack', help='write an archive of the figures')
179+
p.add_argument('filename')
180+
r = sub.add_parser('restore', help='extract an archive of the figures')
181+
r.add_argument('filename')
182+
sub.add_parser('name', help='the archive name for this working tree')
183+
184+
args = parser.parse_args()
185+
if args.command == 'pack':
186+
return pack(args.filename)
187+
if args.command == 'restore':
188+
return restore(args.filename)
189+
return name()
190+
191+
192+
if __name__ == '__main__':
193+
sys.exit(main())

0 commit comments

Comments
 (0)