Skip to content

Commit e4df822

Browse files
kip-hartclaude
andauthored
warm start for the docs build (#115)
* Halve the docs build time of the basalt_circle example The documentation build runs every example in src/microstructpy/examples (docs/source/sphinx_gallery/plot_demos.py globs and runs them all), and nothing is cached between Read the Docs builds. The periodic examples added on this branch pushed that past the 15 minute limit of the Read the Docs free plan. basalt_circle is the single largest consumer at 216 s, 29% of the 741 s the examples take in total, and more than any of the new examples. About 180 s of that is meshing, driven by mesh_max_edge_length on a domain of diameter 10. Measured on the same machine: 0.01 (before) 216.2 s 0.02 114.4 s 0.03 83.7 s 0.05 63.8 s 0.02 halves the example. Coarser values give much less back, since what remains is positioning 1300 seeds, plotting them and verification. The rendered figure is unchanged at publication size, with slightly coarser triangles inside the large olivine grains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * 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> * Fetch the example archive in one step on Read the Docs The name of the archive was worked out in one job step and written to a file, and fetched in the next. If the first step failed, the second fetched an empty name, and the message about a missing archive hid it. Keep the name in a variable in a single step, and say which of the two things happened: no archive was published for this commit, or one was published and refused because its digest is not the digest of this commit. Both leave the examples to run, and the second should not happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Let every documentation format report The matrix cancels the other two formats as soon as one fails, so a html build that stops partway through the examples takes latex and epub with it and there is nothing to compare it against. python_package.yaml already sets fail-fast: false for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Give elliptical_grains.xml a legal XML version The file declared version="0.5". The XML specification allows only '1.' followed by digits, production [26], so the declaration has never been well formed. Older versions of Expat accepted it and newer ones reject it, which is why this has only now started failing: xml.parsers.expat.ExpatError: XML declaration not well-formed: line 1, column 15 Column 15 is where the version value starts. The example is read by xmltodict in cli.input2dict, so the failure stops the whole gallery and takes the documentation build with it. This has been in the file since before this branch, and it is not reproducible everywhere: Expat 2.6.1 parses it without complaint, so a developer can run the examples locally and see nothing wrong while the same commit fails on a runner with a newer Expat. That is what made the documentation check look flaky, failing in one format and passing in another on the same commit. Editing an example changes EXAMPLES_DIGEST, so the archive of figures is invalidated and the examples run again, which is the intended behavior. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Update the example digest for the fixes on develop The eight commits merged from develop change the library and periodic_3D.xml, so the figures the examples produce are no longer the ones in the published archive. The digest check caught it, which is what it is for: EXAMPLES_DIGEST is 314fadcaec7cb6e5, the examples digest to 2d39355b71ddd257. The documentation workflow publishes an archive for the new digest and asks Read the Docs to build again with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 3697c37 commit e4df822

8 files changed

Lines changed: 504 additions & 5 deletions

File tree

‎.github/workflows/documentation.yaml‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,20 @@ 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:
13+
# Let every format report. Without this, one failing format cancels
14+
# the other two, and a build that fails partway through the examples
15+
# takes the evidence of the others with it.
16+
fail-fast: false
1017
matrix:
1118
doc-type: [html, latex, epub]
12-
19+
1320
runs-on: ubuntu-latest
1421
steps:
1522
- name: Checkout
@@ -28,9 +35,62 @@ jobs:
2835
run: |
2936
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
3037
pip install -e .
38+
- name: Check the example digest is current
39+
run: python docs/example_digest.py --check
40+
3141
- name: Build ${{ matrix.doc-type }} documentation
3242
run: sphinx-build -Wnb ${{ matrix.doc-type }} docs/source/ docs/build-${{ matrix.doc-type }}/
33-
43+
44+
# Read the Docs stops a build at fifteen minutes and the examples take
45+
# about twenty, so it restores what the examples produced here instead
46+
# of running them. The archive is named by the digest of the example
47+
# inputs and of the library, and Read the Docs only restores one whose
48+
# digest matches the commit it is building.
49+
- name: Publish the example cache
50+
if: matrix.doc-type == 'html' && github.event.pull_request.head.repo.full_name == github.repository
51+
env:
52+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53+
run: |
54+
name=$(python docs/example_cache.py name)
55+
python docs/example_cache.py pack "$name"
56+
gh release view docs-cache >/dev/null 2>&1 \
57+
|| gh release create docs-cache --title "Documentation example cache" \
58+
--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." \
59+
--latest=false
60+
gh release upload docs-cache "$name" --clobber
61+
62+
# A pull request that changes an example has no cache for its new
63+
# digest until the step above finishes, so the Read the Docs build
64+
# that started alongside this job ran the examples and timed out.
65+
# Build it again now that the archive is published, and it is warm.
66+
#
67+
# Needs a READTHEDOCS_TOKEN secret, from Settings -> API Tokens on
68+
# Read the Docs. Without it this step says so and does nothing, and
69+
# the only cost is having to press Rebuild by hand.
70+
- name: Rebuild the docs on Read the Docs
71+
if: matrix.doc-type == 'html' && github.event.pull_request.head.repo.full_name == github.repository
72+
env:
73+
RTD_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
74+
RTD_VERSION: ${{ github.event.pull_request.number }}
75+
run: |
76+
if [ -z "$RTD_TOKEN" ]; then
77+
echo "No READTHEDOCS_TOKEN secret, skipping the rebuild."
78+
echo "Press Rebuild on the Read the Docs build to pick up the cache."
79+
exit 0
80+
fi
81+
# A pull request build is a version named after the pull request.
82+
code=$(curl -sS -o /tmp/rtd.json -w '%{http_code}' -X POST \
83+
-H "Authorization: Token $RTD_TOKEN" \
84+
"https://app.readthedocs.org/api/v3/projects/microstructpy/versions/$RTD_VERSION/builds/")
85+
echo "Read the Docs answered $code"
86+
cat /tmp/rtd.json
87+
case "$code" in
88+
202) echo "Rebuild triggered." ;;
89+
*) echo "Could not trigger a rebuild. The cache is published, so a"
90+
echo "rebuild by hand will be warm."; exit 0 ;;
91+
esac
92+
93+
3494
- name: Prepare documentation artifact
3595
run: |
3696
# Define Path to Upload

‎.readthedocs.yaml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,30 @@ 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+
- |
30+
set -u
31+
name=$(python docs/example_cache.py name) || exit 0
32+
url=https://github.com/kip-hart/MicroStructPy/releases/download/docs-cache
33+
echo "Looking for $name"
34+
if curl -sSfL -o /tmp/examples.tar.gz "$url/$name"; then
35+
python docs/example_cache.py restore /tmp/examples.tar.gz \
36+
|| echo "The archive was refused, the examples will be run."
37+
else
38+
echo "No archive published for this commit, the examples will be run."
39+
fi
1640
1741
# Build documentation in the "docs/" directory with Sphinx
1842
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)