diff --git a/.github/actions/setup-doc-build/action.yml b/.github/actions/setup-doc-build/action.yml new file mode 100644 index 0000000..612345f --- /dev/null +++ b/.github/actions/setup-doc-build/action.yml @@ -0,0 +1,63 @@ +name: Set up a Clawpack doc build +description: >- + Install the pinned Sphinx toolchain, materialise the pinned Clawpack source + tree, and verify that autodoc can import it. Exports CLAW for later steps. + The repository must already be checked out (a local action cannot check out + the repository it lives in). + +# The docs document a Clawpack *source* tree: doc/conf.py puts $CLAW on +# sys.path and clawpack/clawpack's namespace shim maps clawpack.geoclaw onto +# geoclaw/src/python/geoclaw and so on. Nothing here pip-installs clawpack. +# +# It used to. `pip install clawpack` gets the PyPI release, which declares no +# dependencies at all (so numpy was absent and every autodoc import failed) and +# predates the surge -> met rename the dev docs describe. The result was a +# build that emitted 36 `autodoc: failed to import` warnings and would have +# published a site with empty API pages. +# +# No gfortran either: everything autodoc imports is pure Python. riemann's +# compiled solvers are imported inside a try/except and their absence only +# prints a notice. + +inputs: + python-version: + default: '3.12' + +outputs: + claw: + description: Absolute path of the materialised Clawpack source tree. + value: ${{ steps.claw.outputs.path }} + +runs: + using: composite + steps: + - uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + cache: pip + # setup-python's default globs are **/requirements.txt and + # **/pyproject.toml; this repository has neither, and `cache: pip` + # without this line is a hard error, not a cache miss. + cache-dependency-path: doc/tools/requirements-docs.txt + + - name: Install the documentation toolchain + shell: bash + run: pip install -r doc/tools/requirements-docs.txt + + # Cloned rather than checked out with actions/checkout so that one tracked + # file, doc/tools/clawpack-ref.txt, is the single place the pins live -- + # for CI and for `make claw-pin` alike. + - name: Materialise the pinned Clawpack source tree + id: claw + shell: bash + run: | + python doc/tools/fetch_clawpack_src.py "$RUNNER_TEMP/claw" --quiet + echo "path=$RUNNER_TEMP/claw" >> "$GITHUB_OUTPUT" + echo "CLAW=$RUNNER_TEMP/claw" >> "$GITHUB_ENV" + + # Fails loudly on the class of problem that used to surface only as + # warnings nobody read. + - name: Check the doc build environment + shell: bash + working-directory: doc + run: python tools/check_doc_env.py --check-pin diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..fc83e2e --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,260 @@ +name: docs-publish + +# Build the multiversion documentation and publish it to +# clawpack/clawpack.github.com, which is what www.clawpack.org serves. +# +# This replaces the manual `make versions` + rsync_doc.sh + commit procedure +# in doc/howto_doc.rst. That procedure still works and remains the fallback. +# +# Deliberate design choices, each guarding a specific failure: +# +# * The site repo's Pages stays on the legacy branch-source build. Its +# published tree is ~1.6 GiB, well over the 1 GB documented for Pages; +# legacy has served it for years, whereas `build_type: workflow` enforces +# the cap at deploy time and would be a one-way door. +# +# * The sync is additive and never force-pushes. The published site holds +# ~20 top-level directories no build produces (gallery/, doxygen/, pdf/, +# notebooks/, v5.1.x-v5.6.x, ...). doc/tools/check_published_tree.sh +# asserts they are untouched before anything is committed. +# +# * Publishing is gated on a GitHub Environment with required reviewers, so +# a human approves each write to the live site after seeing the diff. +# +# * Pull requests build a single version only. sphinx-multiversion builds +# from committed refs via `git archive`, never the working tree, so +# `make versions` on a PR would show reviewers the site *without* the PR's +# changes. +# +# * The Clawpack source tree autodoc documents is pinned by commit in +# doc/tools/clawpack-ref.txt and set up by .github/actions/setup-doc-build. +# Every version directory is therefore built against the same source; the +# historical ones get API docs from newer code, which is also what the +# manual `make versions` + rsync_doc.sh procedure always did. + +on: + push: + branches: [dev] + paths: + - 'doc/**' + - '.github/workflows/docs-publish.yml' + pull_request: + branches: [dev, v5.14.x] + paths: + - 'doc/**' + - '.github/workflows/docs-publish.yml' + workflow_dispatch: + inputs: + scope: + description: 'What to publish' + type: choice + options: [dry-run, dev-only, full-site] + default: dry-run + target_branch: + description: 'Branch of clawpack.github.com to push to (ci-preview is inert; master is live)' + default: ci-preview + prune: + description: 'Delete stale files inside rebuilt version dirs' + type: boolean + default: false + +permissions: + contents: read + +# Never cancel in progress: a cancelled publish could leave the site repo +# with a partial sync staged. +concurrency: + group: docs-publish + cancel-in-progress: false + +jobs: + # A PR preview: single version, working tree, no secrets, no publish. + preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-doc-build + + # Single-version build of this PR's working tree, into doc/_build1/html. + - name: Build the docs + run: make -C doc html SPHINXOPTS="-j auto" + + - name: Upload the preview build + uses: actions/upload-artifact@v4 + with: + name: docs-preview-${{ github.event.pull_request.number }} + path: doc/_build1/html + retention-days: 14 + + build: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + # Nine full Sphinx builds, each running autodoc over the clawpack packages. + timeout-minutes: 120 + steps: + # fetch-depth: 0 gets the full history *and* all tags. Both are + # required: sphinx-multiversion builds each version from `git archive` + # of its ref, and most versions are tags. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: ./.github/actions/setup-doc-build + + # actions/checkout leaves only the checked-out ref as a local branch, + # and sphinx-multiversion ignores remote-tracking refs, so v5.14.x + # would silently vanish from the build -- and it is smv_latest_version, + # i.e. the whole site root. This creates the missing local branches + # (reading the whitelist from conf.py) and then asserts the version set. + - name: Check and materialise the version set + working-directory: doc + run: python tools/check_versions.py --create-local-branches + + - name: Build all versions and promote the latest + working-directory: doc + run: make versions-publish SPHINXOPTS="-j auto" + + - name: Check the built site + run: doc/tools/check_built_site.sh doc/_build/html + + - name: Upload the built site + uses: actions/upload-artifact@v4 + with: + name: built-site + path: doc/_build/html + retention-days: 14 + + publish: + needs: build + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/dev') || + (github.event_name == 'workflow_dispatch' && inputs.scope != 'dry-run') + runs-on: ubuntu-latest + # Holds CLAWPACK_SITE_DEPLOY_KEY behind required reviewers. + environment: clawpack-org-website + timeout-minutes: 60 + env: + SCOPE: ${{ github.event_name == 'push' && 'dev-only' || inputs.scope }} + TARGET_BRANCH: ${{ github.event_name == 'push' && 'master' || inputs.target_branch }} + PRUNE: ${{ inputs.prune && '--prune' || '' }} + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Download the built site + uses: actions/download-artifact@v4 + with: + name: built-site + path: site-build + + # A queued run must not publish a tree built from a superseded commit. + - name: Refuse to publish a stale build + if: github.event_name == 'push' + run: | + git fetch --quiet origin dev + head=$(git rev-parse origin/dev) + if [ "$head" != "$GITHUB_SHA" ]; then + echo "origin/dev has moved to $head since this run started ($GITHUB_SHA)." + echo "A newer run will publish; skipping to avoid regressing the site." + exit 1 + fi + + - name: Start ssh-agent with the site deploy key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.CLAWPACK_SITE_DEPLOY_KEY }} + + - name: Clone the published site + run: | + git clone --depth 1 --branch "$TARGET_BRANCH" \ + git@github.com:clawpack/clawpack.github.com.git site + git -C site config user.name "clawpack-doc-publish" + git -C site config user.email "clawpack-doc-publish@users.noreply.github.com" + + - name: Record the current site revision + run: | + prev=$(git -C site rev-parse HEAD) + echo "PREV_SHA=$prev" >> "$GITHUB_ENV" + { + echo "### Publishing to \`$TARGET_BRANCH\` (scope: \`$SCOPE\`)" + echo + echo "Site revision before this publish: \`$prev\`" + echo "To roll back: \`git revert \` in clawpack.github.com" + } >> "$GITHUB_STEP_SUMMARY" + + # Additive by default. --delete appears only for `prune`, and + # check_published_tree.sh independently verifies that nothing outside + # the rebuilt version directories was removed. + - name: Sync the built site into the clone + run: | + delete="" + if [ -n "$PRUNE" ]; then delete="--delete"; fi + + case "$SCOPE" in + dev-only) + rsync -a $delete site-build/dev/ site/dev/ + ;; + full-site) + versions=$(find site-build -mindepth 1 -maxdepth 1 -type d \ + -exec basename {} \; | grep -E '^(dev|v[0-9]+\.[0-9]+\.x)$') + for v in $versions; do + rsync -a $delete "site-build/$v/" "site/$v/" + done + # The promoted root, excluding the version dirs handled above. + rsync -a --exclude='/dev/' --exclude='/v*.*.x/' site-build/ site/ + ;; + *) + echo "unexpected scope: $SCOPE" >&2; exit 1 + ;; + esac + + - name: Assert nothing unmanaged changed + run: doc/tools/check_published_tree.sh site site-build "$PRUNE" + + - name: Report the diff + if: always() + run: | + { + echo + echo '### Changes' + echo '```' + git -C site diff --stat HEAD | tail -20 + echo '```' + echo + echo "New files: $(git -C site ls-files --others --exclude-standard | wc -l)" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Commit and push + run: | + cd site + git add -A + if git diff --cached --quiet; then + echo "Nothing changed; the published site is already up to date." + exit 0 + fi + git commit \ + -m "docs: update $SCOPE from clawpack/doc@$(echo "$GITHUB_SHA" | cut -c1-8)" \ + -m "previous-site-sha: $PREV_SHA" \ + -m "workflow-run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + git push origin "$TARGET_BRANCH" + + - name: Where to verify + run: | + # shellcheck disable=SC2016 # backticks below are literal Markdown + { + echo + if [ "$TARGET_BRANCH" = "master" ]; then + echo 'Live in a few minutes at . Check:' + echo '`gh api repos/clawpack/clawpack.github.com/pages/builds/latest --jq ".status, .error"`' + else + echo "Pushed to \`$TARGET_BRANCH\`, which GitHub Pages does not serve." + echo "Review the diff against \`master\` on github.com." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1712953..c2393a3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,15 +2,19 @@ name: docs # Build the main Clawpack documentation (doc/doc) and fail on any NEW # reStructuredText / docstring warning relative to the committed baseline -# (doc/doc/tools/doc_warnings_baseline.txt). This only parses/renders the -# docs (the `dummy` builder writes no HTML) and does not deploy. +# (doc/tools/doc_warnings_baseline.txt). This only parses/renders the docs +# (the `dummy` builder writes no HTML) and does not deploy. # -# NOTE on the baseline: the set of warnings depends on the build environment -# (which clawpack packages are importable, which optional deps are mocked). -# The committed baseline must therefore be regenerated in THIS environment, -# not on a developer's full source checkout. Run the workflow manually -# (workflow_dispatch) to produce an updated baseline artifact, then commit it. -# Until that is done, treat this check as informational in branch protection. +# The baseline is reproducible because both halves of the environment are +# pinned: the toolchain in doc/tools/requirements-docs.txt and the Clawpack +# source tree in doc/tools/clawpack-ref.txt. A maintainer regenerates it with +# the same two pins locally -- +# +# cd doc && make claw-pin && CLAW=$(cd ../.claw-pin && pwd) make checkwarnings-update +# +# -- in a virtualenv with no clawpack installed, and CI reproduces the result. +# The workflow_dispatch path below remains as a fallback for regenerating it +# on a runner instead. on: pull_request: @@ -24,41 +28,53 @@ on: type: boolean default: false +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + jobs: - checkwarnings: + # Unit tests for the scripts in doc/tools/. No doc toolchain and no source + # tree, so it finishes in seconds and gives a clear signal separate from the + # build itself. + tools-tests: runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 with: python-version: '3.12' + - name: Run the tools tests + working-directory: doc + run: make tools-tests - # gfortran is needed to build the clawpack Fortran extensions on install. - - name: Install system build dependencies - run: sudo apt-get update && sudo apt-get install -y gfortran - - - name: Install the documentation toolchain - run: pip install -r doc/tools/requirements-docs.txt - - # autodoc imports the clawpack subpackages; petclaw/petsc4py is optional - # and mocked in conf.py, so it is intentionally NOT installed here. If a - # different subpackage fails to import, add it to autodoc_mock_imports in - # doc/conf.py rather than installing heavy/optional deps. - - name: Install clawpack (for autodoc imports) - run: pip install clawpack + checkwarnings: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: ./.github/actions/setup-doc-build - name: Check for new documentation warnings if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.update_baseline) }} working-directory: doc run: make checkwarnings - # Manual path: regenerate the baseline in the CI environment and upload it - # so a maintainer can commit the environment-consistent version. + # Manual fallback: regenerate the baseline on the runner and upload it so + # a maintainer can commit it. The pin check is redundant here -- the + # composite action already materialised exactly the pinned tree -- so it + # is skipped rather than re-run. - name: Regenerate baseline if: ${{ github.event_name == 'workflow_dispatch' && inputs.update_baseline }} working-directory: doc - run: make checkwarnings-update + run: make checkwarnings-update ALLOW_UNPINNED=1 - name: Upload regenerated baseline if: ${{ github.event_name == 'workflow_dispatch' && inputs.update_baseline }} diff --git a/.gitignore b/.gitignore index 26f2e21..60d7150 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,22 @@ # ----------------------------------------------------------------------------- # Sphinx build output # ----------------------------------------------------------------------------- -_build1 +# _build1 is the single-version scratch build; _build is the multiversion +# output that gets published. Both are large and neither belongs in git. +_build1/ +_build/ # Generated API docs (if autogenerated) api/generated/ +# ----------------------------------------------------------------------------- +# Pinned Clawpack source tree +# ----------------------------------------------------------------------------- +# `make claw-pin` clones the commits in doc/tools/clawpack-ref.txt here so the +# docs can be built against exactly what CI builds against. Deliberately +# outside doc/, which is the Sphinx source dir. +.claw-pin/ + # ----------------------------------------------------------------------------- # Python # ----------------------------------------------------------------------------- diff --git a/doc/Makefile b/doc/Makefile index 809c048..e9c7c12 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -5,7 +5,23 @@ SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = + +# Two build directories, deliberately separate (see howto_doc.rst): +# BUILDDIR single-version scratch build (`make html`). Fast, but it has +# no site root and its sidebar version links are absent, so it +# is NOT publishable -- it exists so a quick local build cannot +# clobber the multiversion tree. +# VERSIONSDIR multiversion output (`make versions`). This is what gets +# published to clawpack.github.com by `make versions-publish`. BUILDDIR = _build1 +VERSIONSDIR = _build + +# The Clawpack source tree autodoc documents. conf.py defaults to the parent +# of this repository (the ordinary $CLAW layout); `make claw-pin` materialises +# the pinned tree from tools/clawpack-ref.txt here instead, and CLAW selects +# it. Outside the sphinx source dir on purpose -- Sphinx would otherwise walk +# it looking for .rst files. +CLAWPIN = ../.claw-pin # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 @@ -19,7 +35,7 @@ else LAYOUT = _themes/flask_local/layout.html endif -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest checkwarnings checkwarnings-update checkwarnings-strict +.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest checkenv checkwarnings checkwarnings-update checkwarnings-strict check-versions versions clean-versions versions-promote versions-publish claw-pin claw-pin-check tools-tests help: @echo "Please use \`make ' where is one of" @@ -39,9 +55,17 @@ help: @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " versions to build every whitelisted version into $(VERSIONSDIR)/html" + @echo " versions-promote to copy the latest version to the site root and fix its links" + @echo " versions-publish to do a clean versions build + promote (the publishable site)" + @echo " clean-versions to remove $(VERSIONSDIR)" + @echo " claw-pin to clone the Clawpack source tree pinned in tools/clawpack-ref.txt" + @echo " into $(CLAWPIN); build against it with CLAW=\$$(cd $(CLAWPIN) && pwd)" + @echo " checkenv to check that every autodoc target imports from \$$CLAW" @echo " checkwarnings to fail on any NEW reST/docstring warning (vs the baseline)" @echo " checkwarnings-update to regenerate the warning baseline (tools/doc_warnings_baseline.txt)" @echo " checkwarnings-strict to fail on ANY reST/docstring warning, ignoring the baseline" + @echo " tools-tests to run the unit tests for the scripts in tools/" clean: -rm -rf $(BUILDDIR)/* @@ -140,15 +164,73 @@ doctest: @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." -versions: - sphinx-multiversion . _build/html +# Cheap pre-flight: sphinx-multiversion silently skips refs that are not local +# branches, and refs whose conf.py fails to load. Catch that before spending +# a long time on the build. +check-versions: + python tools/check_versions.py + +versions: check-versions + sphinx-multiversion . $(VERSIONSDIR)/html $(SPHINXOPTS) + @echo + @echo "Built one subdirectory per version in $(VERSIONSDIR)/html." + @echo "NOTE: there is no site root yet -- run 'make versions-promote'" + @echo " (or use 'make versions-publish' to do both)." + +clean-versions: + -rm -rf $(VERSIONSDIR) + +versions-promote: + python tools/promote_latest.py $(VERSIONSDIR)/html + +# The single entry point for publishing, used by humans and by CI alike. +# The clean matters: promoting into an already-promoted tree leaves the +# previous version's files stranded at the root. +# +# These are recursive $(MAKE) calls rather than prerequisites so the order +# holds even under `make -j`, where prerequisites could otherwise run +# concurrently and clean the tree out from under the build. +versions-publish: + $(MAKE) clean-versions + $(MAKE) versions + $(MAKE) versions-promote + @echo + @echo "Publishable site is in $(VERSIONSDIR)/html." + @echo "To publish by hand: cd .. && ./rsync_doc.sh" + +# Materialise the pinned Clawpack source tree. --reference borrows objects +# from an ordinary $CLAW checkout next door when there is one, which turns a +# multi-minute clone into a few seconds. +claw-pin: + python tools/fetch_clawpack_src.py $(CLAWPIN) --reference ../.. --quiet + @echo + @echo "Build against it with: CLAW=\$$(cd $(CLAWPIN) && pwd) make checkwarnings" + +claw-pin-check: + python tools/fetch_clawpack_src.py $(CLAWPIN) --check + +checkenv: + python tools/check_doc_env.py checkwarnings: python tools/check_doc_warnings.py +# The baseline is only meaningful against the pinned tree, so regenerating it +# requires one: a baseline written from whatever happened to be checked out is +# how the committed file ended up full of one laptop's absolute paths. +# ALLOW_UNPINNED=1 overrides, for deliberately reseeding after a pin bump. checkwarnings-update: +ifndef ALLOW_UNPINNED + python tools/check_doc_env.py --check-pin +endif python tools/check_doc_warnings.py --update checkwarnings-strict: python tools/check_doc_warnings.py --strict +tools-tests: + python tools/test_check_doc_env.py + python tools/test_check_doc_warnings.py + python tools/test_promote_latest.py + bash tools/test_check_published_tree.sh + diff --git a/doc/conf.py b/doc/conf.py index 83f0094..94ab1e4 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -35,10 +35,20 @@ def _safe_fileConfig(*args, **kwargs): # If your extensions are in another directory, add it here. If the directory # is relative to the documentation root, use os.path.abspath to make it # absolute, like shown here. -sys.path.append(os.path.abspath('../..')) sys.path.append(os.path.abspath('./ext')) - -clawpack_root = os.path.abspath('../..') +sys.path.append(os.path.abspath('./tools')) + +# autodoc documents a Clawpack *source* tree, not an installed clawpack: the +# root below goes on sys.path and clawpack/__init__.py's shim maps +# clawpack.geoclaw onto geoclaw/src/python/geoclaw and so on. Which tree that +# is has to be one decision shared with tools/check_doc_warnings.py, or the +# warning baseline stops being comparable -- hence tools/clawroot.py. +# Set CLAW to build against the pinned tree (`make claw-pin`). +from clawroot import claw_root +clawpack_root = claw_root() +# Ahead of site-packages, so a pip-installed or editable clawpack cannot +# shadow the tree we mean to document. +sys.path.insert(0, clawpack_root) print("clawpack_root = %s" % clawpack_root) sys.path.append(os.path.join(clawpack_root,'amrclaw/doc')) sys.path.append(os.path.join(clawpack_root,'visclaw/doc')) @@ -63,10 +73,17 @@ def _safe_fileConfig(*args, **kwargs): 'srclinks'] -# autodoc imports the documented modules at build time. petclaw/petsc4py is -# optional, heavy, and currently untested in the pip-only doc-build environment -# (including CI), so mock it to keep autodoc imports from failing. Add further -# entries here if other optional/compiled modules fail to import. +# autodoc imports the documented modules at build time, so anything they +# import at module scope has to be installed -- or mocked here. The rule, and +# tools/check_doc_env.py enforces it: a module is either pinned in +# tools/requirements-docs.txt or listed below, never neither. A module that is +# neither makes the build succeed or fail depending on what the person running +# it happens to have installed, which is how the warning baseline stopped being +# portable in the first place. +# +# Mock the heavy and platform-specific ones (petsc4py here; vtk, gdal, tables +# would belong here too if anything imported them eagerly). Pin the light +# pure-wheel ones instead: mocking degrades the rendered signatures. autodoc_mock_imports = ['petsc4py', 'clawpack.petclaw'] @@ -170,11 +187,17 @@ def _safe_fileConfig(*args, **kwargs): # Whitelist pattern for branches (set to None to ignore all branches) # Will show up in list of Latest releases, see _templates/versioning.html -smv_branch_whitelist = r'v5.14.x|dev' +# Anchored at both ends on purpose: sphinx-multiversion matches with re.match, +# which only anchors the start, so an unanchored r'v5.14.x|dev' would also +# match a branch named e.g. 'dev-experiment' and publish it as a version. +smv_branch_whitelist = r'^(dev|v5\.14\.x)$' # For possible use in adding version banners? # see https://holzhaus.github.io/sphinx-multiversion/master/templates.html#version-banners smv_released_pattern = r'v.*' +# Update this at release time (see howto_doc.rst). tools/promote_latest.py +# reads it to decide which version gets copied to the site root, so this is +# the only place the current version needs to be recorded. smv_latest_version = 'v5.14.x' # The theme to use for HTML and HTML Help pages. Major themes that come with diff --git a/doc/fix_links_top_level.py b/doc/fix_links_top_level.py index e1ee39d..f23b784 100644 --- a/doc/fix_links_top_level.py +++ b/doc/fix_links_top_level.py @@ -1,5 +1,20 @@ """ +DEPRECATED: superseded by tools/promote_latest.py, which is what +`make versions-promote` (and `make versions-publish`) runs. + +Prefer the new script. This one is kept only so that older instructions keep +working, and it has two known problems: + + * it only rewrites `*.html`, `riemann/*.html` and `pyclaw/*.html`, so pages + nested any deeper keep one `../` segment too many in their version-switcher + links -- `pyclaw/evolve/limiters.html` is broken on the live site for + exactly this reason; + * it must be paired with `cp -r /* .`, which does not match + dotfiles and so never promotes `.nojekyll` to the site root. + +Original documentation follows. + Script to use with this code for making multi-version docs: https://holzhaus.github.io/sphinx-multiversion/master/index.html diff --git a/doc/geoclaw.rst b/doc/geoclaw.rst index d3bc208..bd97dfc 100644 --- a/doc/geoclaw.rst +++ b/doc/geoclaw.rst @@ -74,7 +74,6 @@ More will eventually appear in the :ref:`apps`. met_forcing surgedata storm_module - netcdf netcdf_utils_module marching_front force_dry diff --git a/doc/howto_doc.rst b/doc/howto_doc.rst index 6bf9ff2..dcb3f72 100644 --- a/doc/howto_doc.rst +++ b/doc/howto_doc.rst @@ -131,9 +131,9 @@ introduced warnings cause a non-zero exit, so you can fix the backlog gradually without the check going red on unrelated pages. If you intentionally add or remove warnings (e.g. after fixing a batch of -them), regenerate and commit the baseline:: - - make checkwarnings-update +them), regenerate and commit the baseline with `make checkwarnings-update` -- +but see :ref:`howto_doc_pinned_tree` first, because it only produces a +reproducible result in the pinned environment. To ignore the baseline entirely and report **every** remaining warning -- the goal once the backlog has been driven to zero -- use:: @@ -141,17 +141,59 @@ the goal once the backlog has been driven to zero -- use:: make checkwarnings-strict The same check runs in CI (`.github/workflows/docs.yml`) on pull requests to -`dev` and the current release branch. Because `autodoc` imports the clawpack -packages, CI installs them with `pip`; the optional parallel package -`petclaw` (and `petsc4py`) is not installed but is instead listed in -`autodoc_mock_imports` in `conf.py`. +`dev` and the current release branch. + +.. _howto_doc_pinned_tree: + +The pinned source tree +^^^^^^^^^^^^^^^^^^^^^^ + +`autodoc` documents a Clawpack **source tree**, not an installed clawpack: +`conf.py` puts `$CLAW` on `sys.path` and the `clawpack/__init__.py` shim in +the `clawpack/clawpack` super-repo maps `clawpack.geoclaw` onto +`geoclaw/src/python/geoclaw`, and so on. So the set of warnings depends on +which Clawpack you are pointing at as much as on the docs themselves, and two +files pin that: + +`tools/requirements-docs.txt` + the Sphinx toolchain and the third-party packages Clawpack imports, all + at exact versions. + +`tools/clawpack-ref.txt` + each Clawpack repository, by commit. + +`make claw-pin` clones the second into `$CLAW/doc/.claw-pin` (borrowing +objects from your own checkouts, so it takes seconds), and `$CLAW` selects +it. Regenerate the baseline against both, in a virtualenv with **no** +clawpack installed:: + + cd $CLAW/doc/doc + python -m venv /tmp/docvenv && /tmp/docvenv/bin/pip install -r tools/requirements-docs.txt + PATH=/tmp/docvenv/bin:$PATH make claw-pin + PATH=/tmp/docvenv/bin:$PATH CLAW=$(cd ../.claw-pin && pwd) make checkwarnings-update + +Then commit `tools/doc_warnings_baseline.txt`. `checkwarnings-update` +refuses to run against a tree that does not match the pin, because that is +exactly how the baseline once filled up with one laptop's absolute paths. + +A fresh virtualenv matters: an editable or pip-installed clawpack registers a +meta-path finder that shadows the source tree whatever `sys.path` says. +`make checkenv` reports that, and every other reason autodoc might not be +able to import what the docs reference:: + + make checkenv + +To bump the pinned Clawpack, edit `tools/clawpack-ref.txt` and repeat the +regeneration above in the same commit. .. note:: - The exact set of warnings depends on which packages are importable, so the - baseline is environment dependent. Regenerate it in the same environment - the CI workflow uses (see `tools/requirements-docs.txt`); the workflow can - be run manually to produce an updated baseline as an artifact. + One warning class is never baselined, however long it has been present: + `autodoc: failed to import` means the API pages for those modules come out + **empty**, which no baseline should be allowed to hide. + `check_doc_warnings.py` reports them separately and fails regardless, and + `tools/check_built_site.sh` independently asserts that the built `dev` + API pages contain generated content before anything is published. **Possible future enhancements:** @@ -162,9 +204,14 @@ packages, CI installs them with `pip`; the optional parallel package (used only for standalone pyclaw builds) for consistency. - Once the baseline is empty, switch CI to `make checkwarnings-strict` and optionally enable nitpicky (`-n`) cross-reference checking. -- Reconcile the build/deploy directory mismatch: `make html` writes to - `_build1/html`, while deployment (below) rsyncs from `_build/html`, the - `make versions` output. +- Migrate off `sphinx-multiversion`, which is unmaintained and pins the + toolchain to `sphinx < 9` (it calls `Config.read()` positionally, and Sphinx + 9.0 made those arguments keyword-only). This bound lives in + `tools/requirements-docs.txt`. +- Restore `v5.1.x`--`v5.6.x` to the multiversion build, or formally retire + them. Their `conf.py` refers to a `plot_directive` extension that no longer + resolves, so `sphinx-multiversion` skips them and only their long-published + HTML remains on the site. To generate docs including previous versions @@ -197,43 +244,72 @@ committed to some branch (normally `dev` if you have been adding something new). And then do this:: cd $CLAW/doc/doc - rm -rf _build # recommended to make sure new versions are clean - make versions + make versions-publish -The `Makefile` has been modified so that `make versions` does this:: +That single target does the whole job: a clean multiversion build followed by +the promotion step described below. It is equivalent to:: - sphinx-multiversion . _build/html + make clean-versions # rm -rf _build + make versions # sphinx-multiversion . _build/html + make versions-promote # python tools/promote_latest.py _build/html -To view the files, point your browser to `_build/html/dev/index.html` -and from there you should be able to navigate to other versions. - -Unlike `sphinxcontrib-versioning`, this now uses your local branches and tags -rather than the versions on Github. It lists only two branches under "Latest -Versions" and all tags as "Older Versions". -The two branches are set to `dev` and the most -recent version, by this line of `conf.py`:: +To view the result, point your browser to `_build/html/index.html`, which is +the current release, and from there you should be able to navigate to other +versions. - smv_branch_whitelist = r'v5.7.x|dev' - -This should be updated for a new version. +Unlike `sphinxcontrib-versioning`, this uses your local branches and tags +rather than the versions on Github. It lists two branches under "Latest +Versions" and the whitelisted tags as "Older Versions". The branches are set +to `dev` and the most recent version, by this line of `conf.py`:: + + smv_branch_whitelist = r'^(dev|v5\.14\.x)$' + +This should be updated for a new version, along with `smv_latest_version`. + +.. warning:: + + `sphinx-multiversion` only considers **local** branches and tags + (`refs/heads/*` and `refs/tags/*`); it ignores remote-tracking refs such as + `clawpack/v5.14.x` unless `smv_remote_whitelist` is set. If you have never + checked out the release branch, it will be missing from your build -- and + because it is `smv_latest_version`, the site would end up with no top-level + pages at all. It also silently skips any ref whose `conf.py` fails to + load. `make versions` therefore runs a pre-flight check first:: + + make check-versions + + which reports exactly which versions will be built and prints the + `git branch` command for anything missing. Tags `v5.1.x` through `v5.6.x` + are known not to build with a current Sphinx (their `conf.py` refers to a + `plot_directive` extension that no longer resolves); they are listed in + `KNOWN_UNBUILDABLE` in `tools/check_versions.py`, and their already + published HTML is left untouched by the deployment step. -Note that `_build/html` contains a subdirectory for each version, but there -are no `.html` files in the top level of `_build/html`. For the Clawpack +Why the promote step is needed +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +`_build/html` contains a subdirectory for each version, but immediately after +`make versions` there are no `.html` files in its top level. For the Clawpack webpage we need to: - Copy the files from the current version to the top level so that - navigating to http://www.clawpack.org/installing.html, + navigating to http://www.clawpack.org/installing.html, for example, goes to the current version of this document. - + - Fix the links in the sidebars of each of these `.html` files so that clicking on `dev`, for example, takes you to http://www.clawpack.org/dev/installing.html - -This can be done as follows:: - cd $CLAW/doc/doc/_build/html - cp -r v5.7.x/* . # replacing v5.7.x with the current version - python ../../fix_links_top_level.py - +`make versions-promote` does both, via `tools/promote_latest.py`. This used to +be a manual `cp -r v5.7.x/* .` plus `python ../../fix_links_top_level.py`; the +script replaces that because the manual form had to be edited by hand at each +release, skipped dotfiles (notably `.nojekyll`, without which GitHub Pages +will not serve `_static`), and only fixed links one or two directories deep. + +You can sanity check the result before deploying:: + + cd $CLAW/doc + ./doc/tools/check_built_site.sh doc/_build/html + If you like what you see, you can push back to your fork and then issue a pull request to have these changes incorporated into the documentation. @@ -299,31 +375,79 @@ updated for this release, the corresponding html files should be too. Updating the webpages --------------------- -A few developers can push html files to the repository +The html files live in the repository `clawpack/clawpack.github.com -`_ +`_ which causes them to show up on the web at `http://clawpack.github.io -`_. +`_. + +.. _howto_doc_publish_ci: + +Publishing with GitHub Actions (preferred) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +`.github/workflows/docs-publish.yml` does the build and the push for you. +Pushing documentation changes to `dev` refreshes `www.clawpack.org/dev/` +automatically; a full-site publish (all versions plus the top-level pages) is +run on demand from the Actions tab via **Run workflow**, choosing: + +`scope` + `dry-run` builds and uploads the site as an artifact without writing + anything; `dev-only` publishes just `dev/`; `full-site` publishes every + version and the promoted top level. + +`target_branch` + `ci-preview` is a branch of `clawpack.github.com` that GitHub Pages does + not serve, so you can inspect a real diff without affecting the live site. + Use `master` only when you mean to publish. -To do so, first create the html files as described above, which should appear -in `doc/doc/_build/html` and `doc/gallery/_build/html`. +`prune` + Off by default. When on, stale files *inside* rebuilt version directories + are deleted. Nothing outside them is ever removed. -Commit any changed source files and +The publish step waits for a reviewer to approve the `clawpack-org-website` +environment, and the run summary shows the diff and the previous site +revision, so you can see exactly what will change before approving and how to +roll back afterwards. + +The gallery is *not* published by CI -- its pages and thumbnails have to be +generated by running the examples (see `gallery/README.md`), so it stays a +manual `rsync` as described below. + +Publishing by hand +^^^^^^^^^^^^^^^^^^^ + +This still works and is the fallback if Actions is unavailable. First create +the html files as described above, which should appear in +`doc/doc/_build/html` and `doc/gallery/_build/html`. + +Commit any changed source files and push to `clawpack/doc `_. Then do:: cd $CLAW/clawpack.github.com - git checkout v5.x.x + git checkout master git pull origin # make sure you are up to date before doing next steps! - cd $CLAW/doc/doc - rsync -azv _build/html/ ../../clawpack.github.com/ - + cd $CLAW/doc + ./rsync_doc.sh + If you have updated the gallery, also do:: - rsync -azv ../gallery/_build/html/ ../../clawpack.github.com/gallery/ + ./rsync_gallery.sh + +Both scripts refuse to run if there is no promoted build to copy, and neither +passes `--delete`: the published site contains many directories that no +current build produces (`gallery/`, `doxygen/`, `pdf/`, `notebooks/`, the +`v5.1.x`--`v5.6.x` trees, and more), and deleting them would take down large +parts of the website. Set `DRYRUN=1` to see what would be copied. + +Before committing, you can run the same guard CI uses:: + + cd $CLAW/doc + ./doc/tools/check_published_tree.sh ../clawpack.github.com doc/_build/html Then move to the `clawpack.github.com` repository and diff --git a/doc/tools/check_built_site.sh b/doc/tools/check_built_site.sh new file mode 100755 index 0000000..12ab87a --- /dev/null +++ b/doc/tools/check_built_site.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# +# Validate a promoted multiversion build before it is allowed anywhere near +# the published site. +# +# Usage: doc/tools/check_built_site.sh [min_versions] +# +# Checks, in order of what they protect against: +# +# 1. A site with no root. `make versions` on its own produces only version +# subdirectories -- see howto_doc.rst. Publishing that would leave +# www.clawpack.org/installing.html a 404. +# 2. An unpromoted or half-promoted tree, detected via the sidebar version +# links: after promotion the root's links must be './dev/', not '../dev/'. +# 3. A missing .nojekyll, without which GitHub Pages refuses to serve the +# _static and _sources directories (every page loses its CSS). +# 4. A missing or wrong CNAME, which would drop the www.clawpack.org custom +# domain. +# 5. A version whitelist that silently dropped versions -- most importantly +# the sphinx-multiversion remote-ref behaviour that makes v5.14.x vanish +# in CI (see .github/workflows/docs-publish.yml). +# 6. A build whose autodoc produced nothing. When the Clawpack source tree +# is missing or unimportable, every `automodule` yields an empty section +# and Sphinx reports it only as a warning -- the site builds, promotes and +# publishes with all of its API pages blank. Checks 1-5 all pass on such +# a tree, so the content itself has to be asserted. + +set -euo pipefail + +HTML=${1:?usage: check_built_site.sh [min_versions]} +# 9 buildable versions: dev, v5.14.x, and tags v5.7.x-v5.13.x. Tags v5.1.x +# through v5.6.x match the whitelist but their conf.py no longer loads, so +# sphinx-multiversion skips them -- see KNOWN_UNBUILDABLE in check_versions.py. +MIN_VERSIONS=${2:-9} +EXPECTED_CNAME=www.clawpack.org + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "ok $*"; } + +[ -d "$HTML" ] || fail "$HTML is not a directory" + +# 1. Site root exists and is not empty. +[ -s "$HTML/index.html" ] \ + || fail "$HTML/index.html missing or empty -- the promote step did not run" +pass "site root index.html present" + +# 2. The promote step rewrote the version-switcher links. +if grep -q 'href="\.\./dev/' "$HTML/index.html"; then + fail "$HTML/index.html still has '../dev/' links -- promote step incomplete" +fi +if ! grep -q 'href="\./dev/' "$HTML/index.html"; then + fail "$HTML/index.html has no './dev/' link -- version switcher missing" +fi +pass "root version-switcher links rewritten to ./dev/" + +# 3 and 4. Pages needs both of these at the root, and both come from +# doc/extra_files via html_extra_path. +[ -f "$HTML/.nojekyll" ] \ + || fail "$HTML/.nojekyll missing -- Pages would not serve _static/_sources" +pass ".nojekyll present" + +[ -f "$HTML/CNAME" ] || fail "$HTML/CNAME missing -- custom domain would drop" +if [ "$(tr -d '[:space:]' < "$HTML/CNAME")" != "$EXPECTED_CNAME" ]; then + fail "$HTML/CNAME is '$(cat "$HTML/CNAME")', expected $EXPECTED_CNAME" +fi +pass "CNAME is $EXPECTED_CNAME" + +# 5. Every version directory is present and non-empty. Version dirs are the +# immediate subdirectories named 'dev' or 'v..x'. +n_versions=0 +missing="" +while IFS= read -r version; do + n_versions=$((n_versions + 1)) + [ -s "$HTML/$version/index.html" ] || missing="$missing $version" +done < <( + find "$HTML" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; \ + | grep -E '^(dev|v[0-9]+\.[0-9]+\.x)$' | sort +) + +[ -z "$missing" ] || fail "version dir(s) with no index.html:$missing" + +if [ "$n_versions" -lt "$MIN_VERSIONS" ]; then + echo "versions found:" >&2 + find "$HTML" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; \ + | grep -E '^(dev|v[0-9]+\.[0-9]+\.x)$' | sort >&2 + fail "found $n_versions version dirs, expected at least $MIN_VERSIONS" +fi +pass "$n_versions version dirs, all with a non-empty index.html" + +# 6. autodoc actually ran. Each probe is a page and a symbol that page can +# only contain if the corresponding Clawpack subpackage was importable, so +# between them they cover geoclaw, pyclaw and riemann. Only `dev` is checked: +# older version dirs come from tags whose page names differ. +api_probe() { + local page="$HTML/dev/$1" symbol="$2" + [ -f "$page" ] || fail "$page missing -- expected an API page for $symbol" + grep -q "$symbol" "$page" \ + || fail "$page does not mention '$symbol' -- autodoc produced no + content, so the published API docs would be empty. Check the build log + for 'autodoc: failed to import', and run tools/check_doc_env.py." +} + +api_probe topotools_module.html clawpack.geoclaw.topotools.Topography +api_probe pyclaw/solution.html clawpack.pyclaw.solution.Solution +pass "dev API pages contain generated autodoc content" + +echo +echo "$HTML looks publishable." diff --git a/doc/tools/check_doc_env.py b/doc/tools/check_doc_env.py new file mode 100644 index 0000000..b0eeae1 --- /dev/null +++ b/doc/tools/check_doc_env.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python +# encoding: utf-8 +r""" +Preflight the environment a Clawpack doc build needs, before Sphinx runs. + +autodoc failures are reported as ordinary warnings buried in a long build log, +so an environment that cannot import Clawpack does not *fail* the build -- it +quietly produces a site whose API pages are empty. That is exactly what +happened to the first docs-publish runs: CI had no sibling source tree, fell +back to a pip-installed clawpack with no dependencies, and emitted 36 +``autodoc: failed to import`` warnings that nothing was watching. + +This script front-loads that check. It collects every autodoc target the +documentation actually references, imports each one, and asserts the module +resolved to the pinned source tree rather than to something on ``sys.path`` by +accident. One legible error instead of 36 opaque warnings. + +Usage +----- + check_doc_env.py [--check-pin] [--verbose] + +``--check-pin`` additionally requires the tree to match +``tools/clawpack-ref.txt``. CI passes it, and so does +``make checkwarnings-update``, since a baseline written against an unpinned +tree is not reproducible. A plain ``make checkenv`` does not, so a developer +with a slightly different checkout can still use it. + +Failures are sorted into three kinds, because they have three different +remedies and printing one blanket suggestion for all of them wastes the +reader's time: + +* the module is **absent from the tree** at ``$CLAW`` -- a stale checkout, + e.g. a geoclaw pinned before the ``surge`` -> ``met`` rename; +* it is **on disk but not importable** -- almost always an installed clawpack + whose file list, not the filesystem, decides what exists; +* it is a **third-party dependency** -- pin it or mock it. + +That taxonomy is not theoretical. A maintainer hit the first two at once and +was told to pin a dependency, which was the remedy for neither. +""" + +from __future__ import annotations + +import argparse +import importlib +import importlib.metadata +import os +import re +import sys +import traceback + + +# Also makes fetch_clawpack_src importable for --check-pin. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from clawroot import CLAW_ROOT_HELP, SRC_DIR, claw_root # noqa: E402 + +CLAW_ROOT = claw_root() + +# The directive matters: `automodule:: X` means X is a module, while +# `autoclass:: X` means X is an attribute of the module X[:-1]. Conflating +# them hides real failures -- stripping the last component of a *module* name +# until something imports would happily "resolve" a broken +# clawpack.geoclaw.topotools to a perfectly healthy clawpack.geoclaw. +_AUTODOC_RE = re.compile( + r'^\s*\.\.\s+auto(module|class|function|exception|data)::\s+(\S+)', + re.MULTILINE) +_MODULE_DIRECTIVES = {'module'} + + +def _mocked_prefixes() -> list[str]: + """The ``autodoc_mock_imports`` list from conf.py, read without importing it. + + conf.py has import-time side effects (it patches logging.config), so it is + parsed rather than executed. + """ + conf = os.path.join(SRC_DIR, 'conf.py') + with open(conf, encoding='utf-8') as fh: + text = fh.read() + m = re.search(r'^autodoc_mock_imports\s*=\s*\[(.*?)\]', text, + re.MULTILINE | re.DOTALL) + if not m: + return [] + return re.findall(r'''['"]([^'"]+)['"]''', m.group(1)) + + +def _missing_name(exc: BaseException) -> str | None: + """The dotted name Python could not find, or ``None``. + + Three shapes, in order of reliability. ``ModuleNotFoundError`` carries + ``name``. A failed ``from . import X`` raises a plain ``ImportError`` + whose ``name`` is the *package* and whose ``name_from`` is the attribute, + so the module is the two joined -- that is the shape of the riemann + failure, where the target that fails is not the module that is missing. + Older interpreters may set neither, hence the message fallback. + """ + name = getattr(exc, 'name', None) + if isinstance(exc, ImportError) and not isinstance(exc, ModuleNotFoundError): + name_from = getattr(exc, 'name_from', None) + if name and name_from: + return f'{name}.{name_from}' + if name: + return name + + text = str(exc) + match = re.search(r"cannot import name '([^']+)' from '?([\w.]+)'?", text) + if match: + return f'{match.group(2)}.{match.group(1)}' + match = re.search(r"No module named '([\w.]+)'", text) + if match: + return match.group(1) + return None + + +def _source_path(dotted: str) -> tuple[str | None, list[str]]: + """``(file the source tree provides or None, paths searched)``. + + Walks prefixes longest-first and resolves the remainder against the real + ``__path__`` of the longest prefix that imports. Shorter prefixes are + tried when a longer one is itself broken, which is what makes + ``clawpack.riemann.euler_mapgrid_3D_constants`` resolvable through + ``clawpack.__path__`` even though importing ``clawpack.riemann`` is + exactly what failed. + """ + parts = dotted.split('.') + searched: list[str] = [] + for split in range(len(parts) - 1, 0, -1): + parent = '.'.join(parts[:split]) + module = sys.modules.get(parent) + if module is None: + try: + module = importlib.import_module(parent) + except Exception: + continue + for entry in getattr(module, '__path__', None) or []: + base = os.path.join(entry, *parts[split:]) + for candidate in (base + '.py', os.path.join(base, '__init__.py')): + searched.append(candidate) + if os.path.exists(candidate): + return candidate, searched + return None, searched + + +def _shadowing_install() -> tuple[str | None, list[str]] | None: + """``(version, finder descriptions)`` if clawpack is installed here. + + Worth reporting even when the build then succeeds: an install decides + what is importable by its own file list, so a doc build against one can + disagree with the checkout for reasons no amount of ``$CLAW`` fiddling + explains. + """ + try: + version = importlib.metadata.version('clawpack') + except importlib.metadata.PackageNotFoundError: + version = None + + finders = [] + for finder in sys.meta_path: + cls = type(finder) + module = cls.__module__ or '' + if module.startswith('mesonpy') or cls.__name__.startswith('Mesonpy'): + finders.append(f'{cls.__name__} (meson-python editable install)') + elif module.startswith('__editable__'): + finders.append(f'{cls.__name__} (setuptools editable install)') + + if version is None and not finders: + return None + return version, finders + + +def collect_targets() -> set[tuple[str, str]]: + """Every ``(directive, dotted-name)`` pair the docs point autodoc at.""" + targets: set[tuple[str, str]] = set() + for dirpath, dirnames, filenames in os.walk(SRC_DIR): + dirnames[:] = [d for d in dirnames + if d not in ('_build', '_build1', '_static', '_templates')] + for name in filenames: + if not name.endswith('.rst'): + continue + with open(os.path.join(dirpath, name), encoding='utf-8', + errors='replace') as fh: + targets.update(_AUTODOC_RE.findall(fh.read())) + return targets + + +def _resolve(directive: str, target: str) -> tuple[str | None, BaseException | None]: + """Import what *target* needs and return ``(module_name, error)``. + + For ``automodule`` the target is the module. For everything else it is an + attribute of its parent module, so the parent is imported and the attribute + is required to exist -- which also catches a class that has been renamed + out from under the docs. + """ + if directive in _MODULE_DIRECTIVES: + module, attr = target, None + elif '.' not in target: + return None, ImportError(f'{target!r} has no module part') + else: + module, attr = target.rsplit('.', 1) + + try: + obj = importlib.import_module(module) + except Exception as exc: + return None, exc + + if attr is not None and not hasattr(obj, attr): + return None, AttributeError( + f'module {module!r} has no attribute {attr!r}') + return module, None + + +def classify(error: BaseException) -> tuple[str, str | None, object]: + """``(kind, missing name, evidence)`` for one import failure. + + ``absent`` a clawpack module the source tree does not contain; + evidence is the list of paths searched. + ``unexposed`` a clawpack module that is on disk yet did not import; + evidence is the file. + ``third_party`` a non-clawpack module; no evidence needed. + ``attribute`` the module imported but the documented name is gone. + ``unknown`` nothing identifiable; evidence is ``None``. + """ + # AttributeError also carries `.name` (of the attribute), so it has to be + # taken out before _missing_name mistakes it for a module. + if isinstance(error, AttributeError): + return 'attribute', getattr(error, 'name', None), None + if not isinstance(error, ImportError): + return 'unknown', None, None + + missing = _missing_name(error) + if missing is None: + return 'unknown', None, None + if missing != 'clawpack' and not missing.startswith('clawpack.'): + return 'third_party', missing, None + path, searched = _source_path(missing) + if path is not None: + return 'unexposed', missing, path + return 'absent', missing, searched + + +def _report_failures(failures: list[tuple[str, BaseException]], + install: tuple[str | None, list[str]] | None) -> None: + """Print the failures grouped by kind, each with the remedy that fits.""" + groups: dict[str, list[tuple[str, BaseException, str | None, object]]] = {} + for target, error in failures: + kind, missing, evidence = classify(error) + groups.setdefault(kind, []).append((target, error, missing, evidence)) + + def header(text: str) -> None: + print(f'\n{text}\n', file=sys.stderr) + + absent = groups.get('absent', []) + if absent: + header(f'{len(absent)} target(s) are not present in the source tree ' + f'at\n{CLAW_ROOT}:') + for target, error, missing, searched in absent: + print(f' {target}\n no {missing} anywhere under $CLAW', + file=sys.stderr) + # Both spellings, module and package: for clawpack.geoclaw.met the + # interesting one is met/__init__.py, not met.py. + for candidate in (searched or [])[:2]: + print(f' looked for {candidate}', file=sys.stderr) + print('\n Your checkout predates these modules -- clawpack/clawpack\'s ' + 'submodule\n pointers lag its subrepos, and the surge -> met ' + 'rename is the usual\n culprit. Update the submodules, or ' + 'build against the pinned tree:\n\n' + ' make claw-pin\n' + ' CLAW=$(cd ../.claw-pin && pwd) make checkenv', + file=sys.stderr) + + unexposed = groups.get('unexposed', []) + if unexposed: + header(f'{len(unexposed)} target(s) exist on disk but did not import:') + for target, error, missing, path in unexposed: + print(f' {target}\n needs {missing}, which is right here:\n' + f' {path}', file=sys.stderr) + if install is not None: + version, finders = install + print(f'\n So the filesystem is not what decides here: clawpack ' + f'{version or "?"} is installed in\n this environment.', + file=sys.stderr) + if finders: + print(f' Imports resolve through its\n {finders[0]},', + file=sys.stderr) + print(' and an install ships exactly the files listed in each ' + 'subpackage\'s\n meson.build -- so a module that is ' + 'tracked and imported but unlisted is\n missing for users ' + 'and present for you. Fix it there, or build the docs\n ' + 'in a virtualenv with no clawpack installed.', + file=sys.stderr) + else: + print('\n Nothing is shadowing $CLAW, so this is not a packaging ' + 'problem: the\n module is on disk and still failed to ' + 'import. Read the error above --\n the fault is inside ' + 'that module, or in what it imports.', file=sys.stderr) + + third_party = groups.get('third_party', []) + if third_party: + header(f'{len(third_party)} target(s) need a third-party package:') + for target, error, missing, _ in third_party: + print(f' {target}\n needs {missing}', file=sys.stderr) + print('\n Either pin it in tools/requirements-docs.txt or add it to ' + 'autodoc_mock_imports\n in conf.py -- a module must be one or ' + 'the other, never neither, or local\n and CI builds will ' + 'disagree.', file=sys.stderr) + + attribute = groups.get('attribute', []) + if attribute: + header(f'{len(attribute)} target(s) import, but the documented name ' + 'is gone:') + for target, error, missing, _ in attribute: + print(f' {target}', file=sys.stderr) + print(' ' + ''.join(traceback.format_exception_only( + type(error), error)).strip(), file=sys.stderr) + print('\n The code was renamed out from under the docs. Update the ' + 'directive in the\n .rst file, or restore the name.', + file=sys.stderr) + + unknown = groups.get('unknown', []) + if unknown: + header(f'{len(unknown)} target(s) failed for reasons this script ' + 'could not classify:') + for target, error, _, _ in unknown: + print(f' {target}', file=sys.stderr) + print(' ' + ''.join(traceback.format_exception_only( + type(error), error)).strip(), file=sys.stderr) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--check-pin', action='store_true', + help='also require the tree to match tools/clawpack-ref.txt') + parser.add_argument('--verbose', action='store_true', + help='print every resolved module path') + args = parser.parse_args(argv) + + print(f'$CLAW = {CLAW_ROOT}') + + install = _shadowing_install() + if install is not None: + version, finders = install + print(f'note: clawpack {version or "(unknown version)"} is installed ' + 'in this environment') + if finders: + print(f' {finders[0]} takes precedence over $CLAW, so what ' + 'is importable is\n decided by that install\'s file ' + 'list, not by the files in the checkout') + else: + print(' $CLAW goes ahead of it on sys.path, so the checkout ' + 'should win; the\n checks below confirm it') + + shim = os.path.join(CLAW_ROOT, 'clawpack', '__init__.py') + if not os.path.exists(shim): + print(f'\nFAIL: {shim} is missing -- there is no Clawpack source tree ' + f'at\n{CLAW_ROOT}.\n\n' + CLAW_ROOT_HELP, file=sys.stderr) + return 1 + + sys.path.insert(0, CLAW_ROOT) + + if args.check_pin: + import fetch_clawpack_src # noqa: E402 (TOOLS_DIR is on sys.path) + if fetch_clawpack_src.check(CLAW_ROOT) != 0: + return 1 + + # An editable/pip-installed clawpack registers a meta-path finder that wins + # over sys.path, so getting this far does not guarantee we are documenting + # the tree we chose. Check the shim itself before anything else imports. + import clawpack # noqa: E402 + if os.path.commonpath([os.path.abspath(clawpack.__file__), CLAW_ROOT]) != CLAW_ROOT: + print(f'\nFAIL: `import clawpack` resolved to\n {clawpack.__file__}\n' + f'which is outside {CLAW_ROOT}.\n\n' + CLAW_ROOT_HELP, + file=sys.stderr) + return 1 + + mocked = _mocked_prefixes() + targets = collect_targets() + if not targets: + print('FAIL: no autodoc directives found under ' + f'{SRC_DIR} -- has the doc layout changed?', file=sys.stderr) + return 1 + + failures: list[tuple[str, BaseException]] = [] + outside: list[tuple[str, str]] = [] + checked = 0 + + for directive, target in sorted(targets, key=lambda t: t[1]): + if any(target == p or target.startswith(p + '.') for p in mocked): + continue + module, error = _resolve(directive, target) + if module is None: + failures.append((target, error)) + continue + checked += 1 + path = getattr(sys.modules.get(module), '__file__', None) + if args.verbose: + print(f' ok {target:<50} {path}') + if path and target.startswith('clawpack.'): + if os.path.commonpath([os.path.abspath(path), CLAW_ROOT]) != CLAW_ROOT: + outside.append((target, path)) + + if failures: + print(f'\nFAIL: {len(failures)} autodoc target(s) cannot be imported.\n' + 'Their API pages would be silently empty in the built site.', + file=sys.stderr) + _report_failures(failures, install) + return 1 + + if outside: + print('\nFAIL: clawpack modules resolved outside the source tree.\n' + 'autodoc would document an installed clawpack instead of the\n' + 'pinned checkout, so the docs and the code would drift apart.\n', + file=sys.stderr) + for target, path in outside: + print(f' {target}\n {path}', file=sys.stderr) + return 1 + + print(f'ok {checked} autodoc target(s) import from {CLAW_ROOT}') + if mocked: + print(f'ok {len(mocked)} mocked prefix(es): {", ".join(mocked)}') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/doc/tools/check_doc_warnings.py b/doc/tools/check_doc_warnings.py index 76cdd8f..a27affe 100644 --- a/doc/tools/check_doc_warnings.py +++ b/doc/tools/check_doc_warnings.py @@ -25,9 +25,22 @@ --strict Ignore the baseline entirely and fail if there are ANY warnings. This is the end goal once the baseline has been driven to empty. -Because autodoc imports the clawpack packages, the set of warnings depends on -the build environment. Regenerate the baseline (``--update``) in the same -environment the CI workflow uses so the signatures agree. +Some warnings are never baselined at all -- see ``_ALWAYS_FAIL``. An +``autodoc: failed to import`` means the API pages for that module come out +empty, which is a silent content loss no baseline should be allowed to hide. + +Reproducibility +--------------- +autodoc imports the Clawpack packages, so the set of warnings is a property of +the environment as much as of the docs. Two things pin it: + + tools/requirements-docs.txt the Sphinx toolchain and clawpack's deps + tools/clawpack-ref.txt the Clawpack source tree, by commit + +Regenerate against both, in a virtualenv with no clawpack installed:: + + make claw-pin + CLAW=$(cd ../.claw-pin && pwd) make checkwarnings-update """ from __future__ import annotations @@ -39,11 +52,11 @@ import sys import tempfile +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from clawroot import SRC_DIR, TOOLS_DIR, claw_root # noqa: E402 -# tools/ -> doc/doc (source dir) -> .../clawpack (the $CLAW root) -TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) -SRC_DIR = os.path.dirname(TOOLS_DIR) -CLAW_ROOT = os.path.abspath(os.path.join(SRC_DIR, os.pardir, os.pardir)) +CLAW_ROOT = claw_root() +DOC_REPO = os.path.dirname(SRC_DIR) # this repository's root BASELINE = os.path.join(TOOLS_DIR, 'doc_warnings_baseline.txt') # A sphinx warning line looks like one of: @@ -55,45 +68,103 @@ r'(?P.*)$' ) +# Warnings that must never be absorbed into the baseline, however long they +# have been around. `autodoc: failed to import` means the API pages for those +# modules are *empty* in the built site -- a silent content loss that looks +# identical to a healthy build unless someone reads the log. Baselining it +# once would hide it forever; the first docs-publish runs emitted 36 of these +# and would have published the result. +_ALWAYS_FAIL = ( + ('autodoc: failed to import', + 'autodoc could not import these -- their API pages would be EMPTY'), +) + +# Absolute paths appear inside warning *messages* as well as in the location +# field: "duplicate label about, other instance in /abs/path/about.rst". Left +# alone they make the baseline machine-specific. +_ABSPATH_RE = re.compile(r'(? str: - """Return *path* relative to the $CLAW root when possible, else unchanged.""" + """Rewrite *path* into a form that does not depend on where things live. + + Two roots, because the docs and the code they document need not be in the + same place any more: $CLAW can point at a pinned tree materialised + somewhere else entirely (see clawroot.py). The doc repository is anchored + on its own so that `doc/doc/topo.rst` means the same thing either way -- + including in the default layout, where the repository sits inside $CLAW and + both roots agree. + """ if not path: return path abspath = path if os.path.isabs(path) else os.path.join(SRC_DIR, path) - try: - rel = os.path.relpath(abspath, CLAW_ROOT) - except ValueError: # different drive on Windows - return path - # Only rewrite paths that actually live under the $CLAW root. - return rel if not rel.startswith(os.pardir) else path + + for root, prefix in ((DOC_REPO, 'doc'), (CLAW_ROOT, '')): + try: + rel = os.path.relpath(abspath, root) + except ValueError: # different drive on Windows + continue + if not rel.startswith(os.pardir): + return os.path.join(prefix, rel) if prefix else rel + + # An installed clawpack still yields a stable suffix, and keeping it + # comparable makes a misconfigured build report a location a reader can act + # on rather than a runner-specific absolute path. + marker = '/site-packages/' + if marker in abspath: + return abspath.split(marker, 1)[1] + return path def _normalize_location(loc: str) -> str: """Drop absolute prefixes and volatile line numbers from a warning's location. - ``/abs/mod.py:docstring of pkg.Cls:7`` -> ``geoclaw/.../mod.py:docstring of pkg.Cls`` + ``/abs/mod.py:docstring of pkg.Cls:7`` -> ``docstring of pkg.Cls`` ``/abs/foo.rst:123`` -> ``doc/doc/foo.rst`` + + Docstring warnings are keyed on the dotted name alone. Where the module + file lives depends on how Clawpack was made importable -- a source tree + under $CLAW, or site-packages -- but ``clawpack.geoclaw.util.bearing`` is + the same object either way, so it is the stable half of the location. """ if not loc: return '' parts = loc.split(':') - filepart = _relativize(parts[0]) # Keep descriptive middle components (e.g. "docstring of ..."), drop pure # line numbers so unrelated edits that shift lines don't churn the baseline. rest = [p for p in parts[1:] if not p.strip().isdigit()] - return ':'.join([filepart] + rest) + if any(p.strip().startswith('docstring of ') for p in rest): + return ':'.join(p.strip() for p in rest) + return ':'.join([_relativize(parts[0])] + rest) + + +def _scrub_message(msg: str) -> str: + """Rewrite absolute paths embedded in a warning message. + + Sphinx names the *other* end of a conflict by absolute path, e.g. + ``duplicate label about, other instance in /abs/doc/doc/about.rst``. + Without this the baseline only matches the machine that wrote it. + """ + return _ABSPATH_RE.sub(lambda m: _relativize(m.group(1)), msg) def _signature(match: 're.Match[str]') -> str: loc = _normalize_location(match.group('loc').strip()) level = match.group('level') - msg = ' '.join(match.group('msg').split()) + msg = _scrub_message(' '.join(match.group('msg').split())) if loc: return f'{loc}: {level}: {msg}' return f'{level}: {msg}' +def _always_fail(signature: str) -> str | None: + """The explanation for *signature* if it can never be baselined, else None.""" + for pattern, explanation in _ALWAYS_FAIL: + if pattern in signature: + return explanation + return None + + def collect_warnings() -> set[str]: """Run a dummy sphinx build and return the set of normalized warning signatures.""" tmp = tempfile.mkdtemp(prefix='doc_warncheck_') @@ -147,15 +218,21 @@ def load_baseline() -> set[str]: def write_baseline(signatures: set[str]) -> None: + """Rewrite the baseline, refusing to record anything in ``_ALWAYS_FAIL``.""" header = ( "# Baseline of pre-existing Clawpack documentation warnings.\n" "# Generated by tools/check_doc_warnings.py --update.\n" "# `make checkwarnings` fails only on warnings NOT listed here.\n" - "# Regenerate in the same environment the CI workflow uses.\n" + "#\n" + "# Reproducible only against the toolchain in tools/requirements-docs.txt\n" + "# and the Clawpack source tree in tools/clawpack-ref.txt. Regenerate\n" + "# with `make claw-pin && CLAW=$(cd ../.claw-pin && pwd) make\n" + "# checkwarnings-update` in a virtualenv with no clawpack installed.\n" ) + recorded = {sig for sig in signatures if _always_fail(sig) is None} with open(BASELINE, 'w', encoding='utf-8') as fh: fh.write(header) - for sig in sorted(signatures): + for sig in sorted(recorded): fh.write(sig + '\n') @@ -169,9 +246,32 @@ def main(argv: list[str] | None = None) -> int: current = collect_warnings() + # Grouped by explanation so each class is reported once, with its reason. + fatal: dict[str, list[str]] = {} + for sig in current: + explanation = _always_fail(sig) + if explanation is not None: + fatal.setdefault(explanation, []).append(sig) + + def report_fatal() -> None: + for explanation, sigs in sorted(fatal.items()): + print(f"\n{len(sigs)} warning(s) that are never baselined -- " + f"{explanation}:\n") + for sig in sorted(sigs): + print(f" {sig}") + print("\nThis usually means the environment, not the docs, is wrong: " + "run\n`python tools/check_doc_env.py` for the underlying import " + "errors.") + if args.update: write_baseline(current) - print(f"Wrote {len(current)} warning(s) to {os.path.relpath(BASELINE, CLAW_ROOT)}") + n = len(current) - sum(len(v) for v in fatal.values()) + # Relative to the sphinx source dir, not $CLAW: $CLAW may be a pinned + # tree somewhere else entirely, and "../doc/tools/..." helps nobody. + print(f"Wrote {n} warning(s) to {os.path.relpath(BASELINE, SRC_DIR)}") + if fatal: + report_fatal() + return 1 return 0 if args.strict: @@ -194,6 +294,13 @@ def main(argv: list[str] | None = None) -> int: print(f" - {sig}") print() + # Reported separately from `new`, and before it: when the environment is + # broken these dominate the diff, and telling someone to run + # `checkwarnings-update` would be exactly the wrong advice. + if fatal: + report_fatal() + return 1 + if new: print(f"{len(new)} NEW documentation warning(s):\n") for sig in sorted(new): diff --git a/doc/tools/check_published_tree.sh b/doc/tools/check_published_tree.sh new file mode 100755 index 0000000..252b49c --- /dev/null +++ b/doc/tools/check_published_tree.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# +# Validate a clawpack.github.com working tree after syncing a built site into +# it, and BEFORE committing. This is the guard that makes an accidentally +# destructive publish impossible rather than merely unlikely. +# +# Usage: doc/tools/check_published_tree.sh [--prune] +# +# site_clone a clone of clawpack/clawpack.github.com with the sync applied +# build_dir the promoted build that was synced in (used to derive the set +# of version directories the build actually produced) +# --prune allow deletions inside version directories only +# +# Why a hardcoded preserve list +# ----------------------------- +# The published site is not a pure build artifact. It accumulated 15 years of +# hand-maintained content that no current build produces: conference link +# pages, doxygen output, notebooks, PDFs, old doc trees. A `rsync --delete` +# or a force-push would silently destroy all of it. Keeping the list in a +# reviewed file, rather than in workflow YAML, means a change to it shows up +# in a pull request diff. + +set -euo pipefail + +SITE=${1:?usage: check_published_tree.sh [--prune]} +BUILD=${2:?usage: check_published_tree.sh [--prune]} +PRUNE=${3:-} + +EXPECTED_CNAME=www.clawpack.org +# See check_built_site.sh: only 9 of the 15 whitelisted refs are buildable. +MIN_VERSIONS=9 + +# Top-level directories in the published site that no doc build produces. +# Verified against the master tree of clawpack/clawpack.github.com. +# +# v5.1.x-v5.6.x are here for a specific reason: they are version directories, +# so they look like build output, but sphinx-multiversion can no longer build +# them (their conf.py fails to load) and the HTML on the site is frozen output +# from an older toolchain. Treating them as unmanaged is what keeps a +# --prune run from deleting documentation we can no longer regenerate. +PRESERVE_DIRS=( + .doctrees + .ipynb_checkpoints + _plots_test + v5.1.x + v5.2.x + v5.3.x + v5.4.x + v5.5.x + v5.6.x + amrclaw + clawdev2016 + doc-5.1.0 + doxygen + gallery + geoclaw + gitwash + hpc3_2014 + junk + list + master + notebooks + old + pdf + sharpclaw + sphinx-versioning +) + +# Subdirectories of build-produced directories that are nonetheless +# hand-maintained. pyclaw/ in particular is only partly build-owned. +PRESERVE_SUBDIRS=( + pyclaw/gallery + pyclaw/devel +) + +# Root files no doc build produces. (.nojekyll, CNAME, README.md, +# clawpack_logos.zip, objects.inv, searchindex.js and .buildinfo ARE produced, +# via doc/extra_files and the promoted version, so they are excluded here.) +PRESERVE_FILES=( + README.txt + clawicon.ico + clawicon_new.ico + clawlogo.jpg + clawlogo_border.jpg + clawlogo_new.jpg + git-clone.py + pyclaw.log + index_old.html + index_redirect.html + index1.html +) + +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "ok $*"; } + +[ -d "$SITE/.git" ] || fail "$SITE is not a git clone" +[ -d "$BUILD" ] || fail "$BUILD is not a directory" + +git_site() { git -C "$SITE" "$@"; } + +# The version directories this build actually produced. Deletions are only +# ever permitted inside these -- deriving the list from the build (rather than +# from a pattern) is what stops a --prune run from touching a version the +# build can no longer regenerate, such as v5.1.x-v5.6.x. +built_versions=$( + find "$BUILD" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; \ + | grep -E '^(dev|v[0-9]+\.[0-9]+\.x)$' | sort +) +[ -n "$built_versions" ] || fail "$BUILD contains no version directories" + +# --------------------------------------------------------------------------- +# 1. Deletions +# --------------------------------------------------------------------------- +deleted=$(git_site diff --diff-filter=D --name-only HEAD) + +if [ -n "$deleted" ]; then + if [ "$PRUNE" != "--prune" ]; then + echo "$deleted" | head -50 >&2 + fail "$(echo "$deleted" | wc -l | tr -d ' ') file(s) would be deleted; \ +pass --prune only if that is intended" + fi + # Even when pruning, deletions are confined to rebuilt version dirs. + allowed=$(echo "$built_versions" | sed 's|$|/|' | paste -sd'|' -) + outside=$(echo "$deleted" | grep -Ev "^($allowed)" || true) + if [ -n "$outside" ]; then + echo "$outside" | head -50 >&2 + fail "--prune allows deletions only inside the version dirs this \ +build regenerated ($(echo "$built_versions" | paste -sd',' -))" + fi + pass "$(echo "$deleted" | wc -l | tr -d ' ') deletion(s), all inside rebuilt version dirs (--prune)" +else + pass "no deletions" +fi + +# --------------------------------------------------------------------------- +# 2. Unmanaged paths are byte-identical +# --------------------------------------------------------------------------- +for path in "${PRESERVE_DIRS[@]}" "${PRESERVE_SUBDIRS[@]}" "${PRESERVE_FILES[@]}"; do + # Absent is fine (the path may not exist on every branch); changed is not. + if [ -e "$SITE/$path" ] || git_site cat-file -e "HEAD:$path" 2>/dev/null; then + if ! git_site diff --quiet HEAD -- "$path"; then + git_site diff --stat HEAD -- "$path" >&2 + fail "unmanaged path '$path' was modified by the sync" + fi + fi +done +pass "${#PRESERVE_DIRS[@]} unmanaged dirs, ${#PRESERVE_SUBDIRS[@]} subdirs and \ +${#PRESERVE_FILES[@]} root files untouched" + +# --------------------------------------------------------------------------- +# 3. Site invariants +# --------------------------------------------------------------------------- +[ -f "$SITE/.nojekyll" ] || fail ".nojekyll missing from $SITE" +[ -f "$SITE/CNAME" ] || fail "CNAME missing from $SITE" +if [ "$(tr -d '[:space:]' < "$SITE/CNAME")" != "$EXPECTED_CNAME" ]; then + fail "CNAME is '$(cat "$SITE/CNAME")', expected $EXPECTED_CNAME" +fi +pass ".nojekyll present and CNAME is $EXPECTED_CNAME" + +# --------------------------------------------------------------------------- +# 4. The site root came from a promoted build +# --------------------------------------------------------------------------- +[ -s "$SITE/index.html" ] || fail "$SITE/index.html missing or empty" +if grep -q 'href="\.\./dev/' "$SITE/index.html"; then + fail "$SITE/index.html has '../dev/' links -- an unpromoted build was synced" +fi +if ! grep -q 'href="\./dev/' "$SITE/index.html"; then + fail "$SITE/index.html has no './dev/' link -- version switcher missing" +fi +pass "site root is a promoted build" + +# --------------------------------------------------------------------------- +# 5. Every version the build produced landed in the site +# --------------------------------------------------------------------------- +n_versions=0 +missing="" +while IFS= read -r version; do + n_versions=$((n_versions + 1)) + [ -s "$SITE/$version/index.html" ] || missing="$missing $version" +done <<< "$built_versions" + +[ -z "$missing" ] || fail "version(s) missing from the site:$missing" +if [ "$n_versions" -lt "$MIN_VERSIONS" ]; then + fail "build had only $n_versions version dirs, expected >= $MIN_VERSIONS" +fi +pass "$n_versions version dirs present in the site" + +# --------------------------------------------------------------------------- +# 6. Change budget, for the human reading the run summary +# --------------------------------------------------------------------------- +echo +echo "Change summary:" +git_site diff --stat HEAD | tail -5 +untracked=$(git_site ls-files --others --exclude-standard | wc -l | tr -d ' ') +echo "new (untracked) files: $untracked" +echo +echo "$SITE is safe to commit." diff --git a/doc/tools/check_versions.py b/doc/tools/check_versions.py new file mode 100644 index 0000000..bb0a23a --- /dev/null +++ b/doc/tools/check_versions.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python +""" +Pre-flight check: report which versions sphinx-multiversion will actually +build, and fail *before* a long build if the set is wrong. + +Run from the Sphinx source directory (doc/):: + + python tools/check_versions.py + +This exists because sphinx-multiversion can silently build fewer versions than +you expect, and the failure is expensive and easy to miss. + +The trap +-------- +``sphinx_multiversion.git.get_refs`` only considers ``refs/heads/*`` and +``refs/tags/*``. Remote-tracking refs are skipped entirely unless +``smv_remote_whitelist`` is set (its default is ``None``, and the relevant +branch reads ``elif ref.is_remote and remote_whitelist is not None``). + +``git clone`` and ``actions/checkout`` create a *local* branch only for the +ref they check out. Everything else is a remote-tracking ref. So on a fresh +CI checkout -- and on any local clone where you have never checked the branch +out -- ``refs/heads/v5.14.x`` does not exist, and v5.14.x is dropped from the +build without any error. Since ``smv_latest_version`` is v5.14.x, and that is +the version promoted to the site root, the result is a published site with no +root at all. + +Rather than set ``smv_remote_whitelist`` (which makes CI and local builds +resolve refs differently and creates duplicate local/remote entries for the +same branch), we require the branches to exist locally and print the exact +command to create any that are missing. + +The second trap +--------------- +sphinx-multiversion also drops any ref whose ``conf.py`` fails to load, with +only a ``Failed load config for ...`` line on stderr and a zero exit status. +The tags v5.1.x through v5.6.x hit this: their ``conf.py`` lists a +``plot_directive`` extension that no longer resolves, and they predate +``sphinx_multiversion`` being added to ``extensions`` (v5.7.x is the first tag +that has it). Their HTML on www.clawpack.org is frozen output from an older +toolchain, and the publish sync is additive, so it survives untouched. + +Those six are therefore recorded in KNOWN_UNBUILDABLE below. The point of +listing them explicitly rather than lowering a threshold is that any *new* +version dropping out still fails this check. +""" + +import argparse +import json +import os +import re +import subprocess +import sys + +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +DOC_DIR = os.path.dirname(TOOLS_DIR) + +# Refs that match the whitelists but whose conf.py cannot be loaded by a +# supported Sphinx. See "The second trap" above. Their content is already +# published and the sync never deletes it, so dropping them is not data loss -- +# but the set must not grow silently. +KNOWN_UNBUILDABLE = { + "v5.1.x", + "v5.2.x", + "v5.3.x", + "v5.4.x", + "v5.5.x", + "v5.6.x", +} + + +def conf_value(source, name): + match = re.search( + r"""^%s\s*=\s*r?['"]([^'"]*)['"]""" % re.escape(name), + source, + re.MULTILINE, + ) + return match.group(1) if match else None + + +def local_refs(): + """Return (branches, tags) that exist as local refs.""" + out = subprocess.run( + ["git", "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags"], + capture_output=True, + text=True, + check=True, + ).stdout + branches, tags = [], [] + for line in out.splitlines(): + if line.startswith("refs/heads/"): + branches.append(line[len("refs/heads/") :]) + elif line.startswith("refs/tags/"): + tags.append(line[len("refs/tags/") :]) + return branches, tags + + +def remote_branches(): + """Return remote-tracking branch names as {short_name: full_ref}.""" + out = subprocess.run( + ["git", "for-each-ref", "--format=%(refname)", "refs/remotes"], + capture_output=True, + text=True, + check=True, + ).stdout + found = {} + for line in out.splitlines(): + parts = line.split("/", 4) # refs/remotes// + if len(parts) < 4: + continue + name = parts[3] if len(parts) == 4 else "/".join(parts[3:]) + if name != "HEAD": + found.setdefault(name, line) + return found + + +def dump_metadata(confdir): + """Return the version names sphinx-multiversion would build, or None.""" + result = subprocess.run( + [ + "sphinx-multiversion", + "--dump-metadata", + confdir, + os.path.join(confdir, "_build", "html"), + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + sys.stderr.write(result.stderr) + return None + + # conf.py prints to stdout (e.g. "clawpack_root = ..."), once per ref, so + # the JSON document does not start at byte 0. Find where it does. + start = result.stdout.find("{") + if start < 0: + sys.stderr.write(result.stdout) + return None + try: + return sorted(json.loads(result.stdout[start:])) + except json.JSONDecodeError: + sys.stderr.write(result.stdout) + return None + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument( + "--conf", default=os.path.join(DOC_DIR, "conf.py"), help="path to conf.py" + ) + parser.add_argument( + "--no-dump", + action="store_true", + help="skip running sphinx-multiversion; check local refs only", + ) + parser.add_argument( + "--create-local-branches", + action="store_true", + help="create local branches for whitelisted remote branches that are " + "missing (for CI, where actions/checkout leaves only one local " + "branch); without this the command is printed for you to run", + ) + args = parser.parse_args(argv) + + with open(args.conf) as f: + source = f.read() + + latest = conf_value(source, "smv_latest_version") + branch_pat = conf_value(source, "smv_branch_whitelist") + tag_pat = conf_value(source, "smv_tag_whitelist") + + if not latest: + sys.exit("conf.py does not define smv_latest_version") + + branches, tags = local_refs() + + # Materialise whitelisted branches that exist only as remote-tracking + # refs. Opt-in, because it mutates the repository: CI passes the flag, + # humans get the command printed instead (see below). + if args.create_local_branches: + remotes = remote_branches() + for name, ref in sorted(remotes.items()): + if name in branches or not re.match(branch_pat, name): + continue + print("creating local branch %s from %s" % (name, ref)) + subprocess.run(["git", "branch", name, ref], check=True) + branches, tags = local_refs() + + wanted_branches = [b for b in branches if re.match(branch_pat, b)] + wanted_tags = [t for t in tags if re.match(tag_pat, t)] + expected = sorted(wanted_branches + wanted_tags) + + print("smv_branch_whitelist = %r -> local branches: %s" + % (branch_pat, ", ".join(sorted(wanted_branches)) or "(none)")) + print("smv_tag_whitelist = %r -> %d local tag(s)" + % (tag_pat, len(wanted_tags))) + print("smv_latest_version = %r" % latest) + print("expected versions (%d): %s" % (len(expected), ", ".join(expected))) + + # The critical check: is the version that becomes the site root present? + if latest not in expected: + remotes = remote_branches() + print() + if latest in remotes: + sys.exit( + "ERROR: %s matches the branch whitelist but exists only as a " + "remote-tracking ref (%s), which sphinx-multiversion ignores.\n" + "It is smv_latest_version, so the build would have no site " + "root.\n\nCreate the local branch first:\n\n" + " git branch %s %s\n" + % (latest, remotes[latest], latest, remotes[latest]) + ) + sys.exit( + "ERROR: smv_latest_version (%s) is not among the refs that would " + "be built.\nEither create it locally or update conf.py." % latest + ) + + if args.no_dump: + print("\nLocal refs look right (skipped sphinx-multiversion).") + return 0 + + built = dump_metadata(DOC_DIR) + if built is None: + sys.exit("sphinx-multiversion --dump-metadata failed; see above") + + print("\nsphinx-multiversion will build (%d): %s" + % (len(built), ", ".join(built))) + + dropped = set(expected) - set(built) + unexpected = sorted(set(built) - set(expected)) + new_drops = sorted(dropped - KNOWN_UNBUILDABLE) + recovered = sorted(KNOWN_UNBUILDABLE & set(built)) + + if sorted(dropped & KNOWN_UNBUILDABLE): + print("\nknown-unbuildable, skipped as expected (%d): %s" + % (len(dropped & KNOWN_UNBUILDABLE), + ", ".join(sorted(dropped & KNOWN_UNBUILDABLE)))) + + if latest not in built: + sys.exit( + "ERROR: smv_latest_version (%s) is not in the build; the site " + "would have no root." % latest + ) + + if new_drops: + sys.exit( + "ERROR: version(s) dropped out of the build that used to be " + "there: %s\nsphinx-multiversion skips any ref whose conf.py " + "fails to load, and only says so on stderr. Check the " + "'Failed load config' lines above." % ", ".join(new_drops) + ) + + if unexpected: + sys.exit( + "ERROR: unexpected version(s) in the build: %s" + % ", ".join(unexpected) + ) + + if recovered: + print( + "\nNOTE: %s now build(s) successfully; remove from " + "KNOWN_UNBUILDABLE in %s." + % (", ".join(recovered), os.path.basename(__file__)) + ) + + print("\nVersion set is consistent (%d buildable)." % len(built)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/doc/tools/clawpack-ref.txt b/doc/tools/clawpack-ref.txt new file mode 100644 index 0000000..90ed431 --- /dev/null +++ b/doc/tools/clawpack-ref.txt @@ -0,0 +1,45 @@ +# The Clawpack source tree the documentation is built against. +# +# WHY THIS FILE EXISTS +# +# doc/conf.py does `sys.path.append(os.path.abspath('../..'))`, i.e. the parent +# of this repository, and clawpack/clawpack's `clawpack/__init__.py` shim then +# extends __path__ to the sibling source trees (geoclaw/src/python, pyclaw/src, +# ...). So autodoc documents *source checkouts*, never an installed clawpack. +# A doc build is therefore only reproducible if those checkouts are pinned. +# +# Without a pin the warning baseline (doc_warnings_baseline.txt) is a moving +# target: any docstring edit anywhere in Clawpack would turn doc CI red, and +# the baseline would only ever match the machine that last regenerated it. +# +# WHY A FLAT MANIFEST AND NOT JUST THE SUPER-REPO SHA +# +# clawpack/clawpack's own submodule pointers lag its subrepos. As of this +# writing master (73649ee) records geoclaw 11479f67, which predates the +# surge -> met rename that doc/met_forcing.rst and doc/storm_module.rst +# document. Pinning only the super-repo would therefore pin a tree the docs +# cannot be built against. Each repo is pinned explicitly instead. +# +# When clawpack/clawpack's submodule pointers catch up, these should be set +# from `git ls-tree ` so the two agree. +# +# HOW TO BUMP +# +# 1. Edit the SHAs below (they must be full 40-character, *pushed* commits; +# CI clones from github.com and cannot see local-only branches). +# 2. make claw-pin # materialise the pinned tree +# 3. make checkwarnings-update # regenerate the baseline against it +# 4. Commit this file and doc_warnings_baseline.txt together. +# +# Format: . `clawpack` is the super-repo and provides the +# namespace shim; the rest are cloned into it as its submodule directories +# would be. All are cloned from https://github.com/clawpack/. + +clawpack 73649ee8d86220a63ae35e45ad1d5b97102e06ea +amrclaw 9044e2f9f6fad9d425f6f1b21ca7ebbae902ca2c +classic 9cedf986668f5c91c1ee1e0c2796232d4cc8bee3 +clawutil f6f56af449e54af6007006b5688c1355659d5015 +geoclaw 5b6a02f3de967baffb2c5ca3fe456bc89954370f +pyclaw f522337ef75abef1153e2025a204b7ef4f7c5c9f +riemann fe929bf2403c0ef759e0892998bc80ac84fcfe67 +visclaw eb16e086b72f66c88efd0d9d76d7b9aacd3e0cd9 diff --git a/doc/tools/clawroot.py b/doc/tools/clawroot.py new file mode 100644 index 0000000..b0c1364 --- /dev/null +++ b/doc/tools/clawroot.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +# encoding: utf-8 +r""" +One definition of "which Clawpack source tree are we documenting?". + +``conf.py`` and every tool under ``tools/`` must agree on this, or the warning +baseline stops being comparable: ``check_doc_warnings.py`` rewrites warning +locations relative to this root, so a tool that picks a different root produces +different signatures for the same warning. + +The default is the parent of this repository -- the ordinary ``$CLAW`` layout, +where ``doc`` sits next to ``geoclaw``, ``pyclaw`` and the rest, and +``clawpack/__init__.py`` maps ``clawpack.geoclaw`` onto +``geoclaw/src/python/geoclaw``. + +Setting ``CLAW`` overrides it. That is how the docs get built against the +pinned tree (``tools/clawpack-ref.txt``, ``make claw-pin``) without anyone +having to move their checkout around, and it is what CI uses. +""" + +from __future__ import annotations + +import os + + +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +SRC_DIR = os.path.dirname(TOOLS_DIR) # doc/doc -- the sphinx source dir + +CLAW_ROOT_HELP = """\ +The docs are built against a Clawpack source tree, selected by $CLAW and +defaulting to the parent of this repository. To build against the pinned +tree instead: + + make claw-pin # clone tools/clawpack-ref.txt into ../.claw-pin + CLAW=$(cd ../.claw-pin && pwd) make checkwarnings + +Use a virtualenv with no clawpack installed: an editable or pip-installed +clawpack registers a meta-path finder that shadows the source tree no matter +what sys.path says.""" + + +def claw_root() -> str: + """Absolute path of the Clawpack source tree to document.""" + override = os.environ.get('CLAW') + if override: + return os.path.abspath(override) + return os.path.abspath(os.path.join(SRC_DIR, os.pardir, os.pardir)) diff --git a/doc/tools/doc_warnings_baseline.txt b/doc/tools/doc_warnings_baseline.txt index f28304b..446726b 100644 --- a/doc/tools/doc_warnings_baseline.txt +++ b/doc/tools/doc_warnings_baseline.txt @@ -1,7 +1,11 @@ # Baseline of pre-existing Clawpack documentation warnings. # Generated by tools/check_doc_warnings.py --update. # `make checkwarnings` fails only on warnings NOT listed here. -# Regenerate in the same environment the CI workflow uses. +# +# Reproducible only against the toolchain in tools/requirements-docs.txt +# and the Clawpack source tree in tools/clawpack-ref.txt. Regenerate +# with `make claw-pin && CLAW=$(cd ../.claw-pin && pwd) make +# checkwarnings-update` in a virtualenv with no clawpack installed. WARNING: A mocked object is detected: 'clawpack.petclaw.geometry.Domain' [autodoc.mocked_object] doc/doc/ClawPlotData.rst: WARNING: undefined label: 'clawsolution' [ref.ref] doc/doc/ClawPlotFigure.rst: WARNING: duplicate object description of gethandle, other instance in ClawPlotAxes, use :no-index: for one of them @@ -44,7 +48,7 @@ doc/doc/plotting_faq.rst: WARNING: undefined label: 'clawplotfigure`' [ref.ref] doc/doc/plotting_faq.rst: WARNING: undefined label: 'clawplotitem`' [ref.ref] doc/doc/plotting_faq.rst: WARNING: undefined label: 'plotexample-acou-1d-6' [ref.ref] doc/doc/plotting_python.rst: WARNING: undefined label: 'python-install' [ref.ref] -doc/doc/pyclaw/about.rst: WARNING: duplicate label about, other instance in /Users/mandli/src/clawpack/doc/doc/about.rst +doc/doc/pyclaw/about.rst: WARNING: duplicate label about, other instance in doc/doc/about.rst doc/doc/pyclaw/about.rst: WARNING: undefined label: 'develop' [ref.ref] doc/doc/pyclaw/cloud.rst: WARNING: undefined label: 'notebooks' [ref.ref] doc/doc/pyclaw/index.rst: WARNING: undefined label: 'visclaw' [ref.ref] @@ -59,14 +63,15 @@ doc/doc/ruled_rectangles.rst: WARNING: undefined label: 'refinement-regions' [re doc/doc/setplot.rst: WARNING: undefined label: 'plotfigure' [ref.ref] doc/doc/setrun_geoclaw.rst: WARNING: undefined label: 'regions' [ref.ref] doc/doc/setrun_geoclaw.rst: WARNING: undefined label: 'setrun_setgeo' [ref.ref] -doc/doc/topo.rst: WARNING: duplicate label qinit_file, other instance in /Users/mandli/src/clawpack/doc/doc/dtopo.rst +doc/doc/topo.rst: WARNING: duplicate label qinit_file, other instance in doc/doc/dtopo.rst doc/doc/topo.rst: WARNING: undefined label: 'g_input' [ref.ref] doc/doc/tsunamidata.rst: WARNING: undefined label: 'topo_netcdf' [ref.ref] -geoclaw/src/python/geoclaw/fgmax_tools.py:docstring of clawpack.geoclaw.fgmax_tools.FGmaxGrid.read_output: ERROR: Unexpected indentation. [docutils] -geoclaw/src/python/geoclaw/fgmax_tools.py:docstring of clawpack.geoclaw.fgmax_tools.FGmaxGrid.read_output: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils] -geoclaw/src/python/geoclaw/netcdf_utils.py:docstring of clawpack.geoclaw.netcdf_utils.CFNormalizer: ERROR: Unexpected indentation. [docutils] -geoclaw/src/python/geoclaw/netcdf_utils.py:docstring of clawpack.geoclaw.netcdf_utils.CFNormalizer: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils] -geoclaw/src/python/geoclaw/topotools.py:docstring of clawpack.geoclaw.topotools.Topography.write: WARNING: undefined label: 'topo_netcdf' [ref.ref] -geoclaw/src/python/geoclaw/topotools.py:docstring of clawpack.geoclaw.topotools.fetch_topo_url: ERROR: Unknown target name: "http://www.geoclaw.org/topo". [docutils] -geoclaw/src/python/geoclaw/util.py:docstring of clawpack.geoclaw.util.bearing: ERROR: Unexpected indentation. [docutils] -pyclaw/src/pyclaw/limiters/tvd.py:docstring of clawpack.pyclaw.limiters.tvd: WARNING: citation not found: kemm_2009 [ref.ref] +docstring of clawpack.geoclaw.fgmax_tools.FGmaxGrid.read_output: ERROR: Unexpected indentation. [docutils] +docstring of clawpack.geoclaw.fgmax_tools.FGmaxGrid.read_output: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils] +docstring of clawpack.geoclaw.met.track.iter_ibtracs: WARNING: Inline interpreted text or phrase reference start-string without end-string. [docutils] +docstring of clawpack.geoclaw.topotools.Topography.write: WARNING: undefined label: 'topo_netcdf' [ref.ref] +docstring of clawpack.geoclaw.topotools.fetch_remote_topo: ERROR: Unexpected indentation. [docutils] +docstring of clawpack.geoclaw.topotools.fetch_remote_topo: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils] +docstring of clawpack.geoclaw.topotools.fetch_topo_url: ERROR: Unknown target name: "http://www.geoclaw.org/topo". [docutils] +docstring of clawpack.geoclaw.util.bearing: ERROR: Unexpected indentation. [docutils] +docstring of clawpack.pyclaw.limiters.tvd: WARNING: citation not found: kemm_2009 [ref.ref] diff --git a/doc/tools/fetch_clawpack_src.py b/doc/tools/fetch_clawpack_src.py new file mode 100644 index 0000000..026dae8 --- /dev/null +++ b/doc/tools/fetch_clawpack_src.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python +# encoding: utf-8 +r""" +Materialise the pinned Clawpack source tree the docs are built against. + +``doc/conf.py`` puts the *parent of this repository* on ``sys.path`` and relies +on clawpack/clawpack's ``clawpack/__init__.py`` shim to map ``clawpack.geoclaw`` +onto ``geoclaw/src/python/geoclaw`` and friends. autodoc therefore documents +source checkouts, and the doc build is reproducible only if those checkouts are +pinned. The pins live in ``tools/clawpack-ref.txt``; this script turns them +into a real directory tree:: + + /clawpack/__init__.py the namespace shim (from the super-repo) + /geoclaw/ ... each subrepo, detached at its pinned SHA + /doc/ this repository (created by the caller) + +Usage +----- + fetch_clawpack_src.py [--reference DIR] [--quiet] + fetch_clawpack_src.py --check + +``--reference DIR`` treats ``DIR`` as a directory of existing Clawpack clones +(e.g. an ordinary ``$CLAW``) and borrows their objects, which makes a local +materialisation nearly instant and mostly offline. Objects are copied rather +than shared, so the result stays valid if the reference clones later move. + +``--check`` verifies an existing tree matches the manifest and exits non-zero +otherwise. ``make checkwarnings-update`` uses it to refuse to write a baseline +from an unpinned tree. +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys + + +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +MANIFEST = os.path.join(TOOLS_DIR, 'clawpack-ref.txt') + +# The super-repo is checked out as itself, not /clawpack: it is +# what carries the clawpack/ shim package and the submodule directory layout. +SUPER_REPO = 'clawpack' + +_SHA_RE = re.compile(r'^[0-9a-f]{40}$') + + +def read_manifest(path: str = MANIFEST) -> list[tuple[str, str]]: + """Return the manifest as an ordered list of ``(repo, sha)`` pairs.""" + entries = [] + with open(path, encoding='utf-8') as fh: + for lineno, line in enumerate(fh, 1): + line = line.split('#', 1)[0].strip() + if not line: + continue + fields = line.split() + if len(fields) != 2: + raise SystemExit( + f'{path}:{lineno}: expected " ", got: {line!r}') + repo, sha = fields + if not _SHA_RE.match(sha): + raise SystemExit( + f'{path}:{lineno}: {repo} is not pinned to a full ' + f'40-character SHA: {sha!r}') + entries.append((repo, sha)) + if not any(repo == SUPER_REPO for repo, _ in entries): + raise SystemExit(f'{path}: no "{SUPER_REPO}" entry; the shim package ' + 'that makes `import clawpack.geoclaw` work comes ' + 'from the super-repo') + return entries + + +def _run(cmd: list[str], cwd: str | None = None, quiet: bool = False) -> None: + if not quiet: + print(' $ ' + ' '.join(cmd), flush=True) + subprocess.run(cmd, cwd=cwd, check=True, + stdout=subprocess.DEVNULL if quiet else None) + + +def _head(repo_dir: str) -> str | None: + """The checked-out SHA of *repo_dir*, or None if it is not a git repo.""" + try: + out = subprocess.run(['git', '-C', repo_dir, 'rev-parse', 'HEAD'], + capture_output=True, text=True, check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + return out.stdout.strip() + + +def _materialise(repo: str, sha: str, target: str, reference: str | None, + quiet: bool) -> None: + """Ensure *target* is a checkout of clawpack/ detached at *sha*.""" + if _head(target) == sha: + if not quiet: + print(f'{repo}: already at {sha[:8]}') + return + + url = f'https://github.com/clawpack/{repo}.git' + if not os.path.isdir(os.path.join(target, '.git')): + os.makedirs(target, exist_ok=True) + clone = ['git', 'clone', '--quiet', '--no-checkout'] + if reference: + ref_dir = os.path.join(reference, repo) + if os.path.isdir(os.path.join(ref_dir, '.git')): + # --dissociate copies the borrowed objects in, so the result + # does not break if the reference clone is later pruned. + clone += ['--reference-if-able', ref_dir, '--dissociate'] + _run(clone + [url, target], quiet=quiet) + + # `git fetch ` works for any commit the server will serve, + # including ones not reachable from a branch tip we happen to have. + _run(['git', '-C', target, 'fetch', '--quiet', '--tags', url, sha], + quiet=quiet) + _run(['git', '-C', target, 'checkout', '--quiet', '--detach', sha], + quiet=quiet) + print(f'{repo}: {sha[:8]}') + + +def materialise(dest: str, reference: str | None = None, + quiet: bool = False) -> None: + """Build the pinned $CLAW tree at *dest*.""" + entries = read_manifest() + dest = os.path.abspath(dest) + + # The super-repo first: it owns itself, and the subrepos land in + # the (empty) submodule directories it provides. + for repo, sha in entries: + target = dest if repo == SUPER_REPO else os.path.join(dest, repo) + _materialise(repo, sha, target, reference, quiet) + + shim = os.path.join(dest, SUPER_REPO, '__init__.py') + if not os.path.exists(shim): + raise SystemExit(f'{shim} missing -- `import clawpack.geoclaw` cannot ' + 'work without the super-repo namespace shim') + + +def check(dest: str) -> int: + """Report whether *dest* matches the manifest. Returns an exit status.""" + entries = read_manifest() + dest = os.path.abspath(dest) + problems = [] + for repo, sha in entries: + target = dest if repo == SUPER_REPO else os.path.join(dest, repo) + head = _head(target) + if head is None: + problems.append(f' {repo:<10} MISSING (want {sha[:8]})') + elif head != sha: + problems.append(f' {repo:<10} {head[:8]} != pinned {sha[:8]}') + + if problems: + print(f'{dest} does not match tools/clawpack-ref.txt:\n') + print('\n'.join(problems)) + print('\nThe warning baseline is only reproducible against the pinned ' + 'tree.\nRun `make claw-pin` to materialise it, or bump ' + 'tools/clawpack-ref.txt.') + return 1 + + print(f'{dest} matches tools/clawpack-ref.txt ({len(entries)} repos).') + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('dest', help='directory to build the $CLAW tree in') + parser.add_argument('--reference', metavar='DIR', + help='borrow objects from existing clones under DIR') + parser.add_argument('--check', action='store_true', + help='verify an existing tree instead of building one') + parser.add_argument('--quiet', action='store_true', + help='only report the resulting SHAs') + args = parser.parse_args(argv) + + if args.check: + return check(args.dest) + + materialise(args.dest, args.reference, args.quiet) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/doc/tools/promote_latest.py b/doc/tools/promote_latest.py new file mode 100755 index 0000000..63d7484 --- /dev/null +++ b/doc/tools/promote_latest.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +""" +Promote the latest documentation version to the root of a multi-version build. + +This replaces the manual procedure that used to be documented in howto_doc.rst +and implemented by doc/fix_links_top_level.py. The original text of that +script is preserved below because it is the only written record of *why* the +step exists: + + Script to use with this code for making multi-version docs: + https://holzhaus.github.io/sphinx-multiversion/master/index.html + + Using sphinx-multiversion creates a _build/html directory that has a + subdirectory for each version. But the current version .html files are not + in _build/html so you can only reach them if you point to a specific + version. + + We copy _build/html/* to $CLAW/clawpack.github.org/ for hosting on the web. + + We want e.g. www.clawpack.org/installing.html to point to the current + release (without having to specify e.g. + www.clawpack.org/v5.6.1/installing.html). This can be accomplished by + copying the v5.6.1/* files up a level, but then the links in the sidebar + don't work properly for reaching other versions. + + This script fixes those links. + +Why this exists as a script rather than two shell lines +------------------------------------------------------- +The documented procedure was:: + + cd _build/html + cp -r v5.7.x/* . # replace v5.7.x with the current version + python ../../fix_links_top_level.py + +That had three problems: + +1. The version number was hardcoded in prose, so it had to be remembered at + release time. We read ``smv_latest_version`` from ``conf.py`` instead, so + there is exactly one place to update. + +2. ``cp -r v5.7.x/*`` does not match dotfiles, so ``.nojekyll`` (and + ``CNAME``, ``.buildinfo``) were never promoted to the root. The published + site has a root ``.nojekyll`` only because nothing has ever deleted it -- + and without it GitHub Pages would refuse to serve the ``_static`` and + ``_sources`` directories. We copy the directory *contents* including + dotfiles. + +3. ``fix_links_top_level.py`` only rewrote ``*.html``, ``riemann/*.html`` and + ``pyclaw/*.html``, so pages nested any deeper kept a wrong number of + ``../`` segments in their version-switcher links. We rewrite every HTML + file at whatever depth it happens to live. + +How the link rewrite works +-------------------------- +``sphinx_multiversion.sphinx.VersionInfo.vpathto`` builds each switcher link as +a path relative to the *current page*, from one version directory across to +another:: + + /v5.14.x/about.html -> "../dev/about.html" + /v5.14.x/pyclaw/about.html -> "../../dev/pyclaw/about.html" + +i.e. a page at depth ``d`` inside its version directory gets ``d + 1`` leading +``../`` segments. Promoting that version's tree up one level (to the site +root) removes one level of nesting, so every such link needs exactly one fewer +``../``. At depth 0 there is no ``../`` left, so the link becomes ``./``. + +We match on the *exact* version directory names taken from the build, e.g. +``../dev/`` and ``../../v5.12.x/``, rather than on the ``../dev`` / ``../v5`` +substrings the old script used. That matters: a substring rewrite of ``../v5`` +would also corrupt an ordinary relative link to any path beginning with +``v5``, and it silently did nothing for versions not named ``dev`` or ``v5*``. +""" + +import argparse +import os +import re +import shutil +import sys + +# Directory holding this script (doc/tools), and the Sphinx source dir (doc). +TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) +DOC_DIR = os.path.dirname(TOOLS_DIR) + + +def read_latest_version(conf_py): + """Return ``smv_latest_version`` as declared in *conf_py*. + + Parsed textually rather than by importing conf.py: importing it pulls in + the whole Sphinx configuration (and the clawpack packages autodoc needs), + which we neither want nor need in order to read one string. + """ + with open(conf_py, "r") as f: + source = f.read() + + match = re.search( + r"""^smv_latest_version\s*=\s*['"]([^'"]+)['"]""", + source, + re.MULTILINE, + ) + if match is None: + sys.exit( + "%s does not define smv_latest_version; cannot tell which " + "version to promote to the site root." % conf_py + ) + return match.group(1) + + +def find_version_dirs(html_dir): + """Return the version directory names in *html_dir*, sorted. + + sphinx-multiversion writes one directory per built ref into the output + root and nothing else, so the immediate subdirectories *are* the versions. + This must be called before anything is copied to the root, since promoting + adds the latest version's own subdirectories (pyclaw/, riemann/, ...) + alongside them. + """ + return sorted( + name + for name in os.listdir(html_dir) + if os.path.isdir(os.path.join(html_dir, name)) + ) + + +def copy_to_root(html_dir, latest): + """Copy the contents of ``html_dir/latest`` up into ``html_dir``. + + Includes dotfiles (``.nojekyll``, ``CNAME``, ``.buildinfo``), which the + documented ``cp -r /*`` silently skipped. Existing files are + overwritten; unrelated files already at the root are left alone, so this + is additive with respect to anything the build did not produce. + """ + src = os.path.join(html_dir, latest) + for name in sorted(os.listdir(src)): + src_path = os.path.join(src, name) + dest_path = os.path.join(html_dir, name) + if os.path.isdir(src_path): + shutil.copytree(src_path, dest_path, dirs_exist_ok=True) + else: + shutil.copy2(src_path, dest_path) + + +def build_replacements(version_dirs, depth): + """Return (old, new) link-prefix pairs for a page at *depth*. + + A page at depth ``d`` in a version directory refers to sibling versions + with ``d + 1`` leading ``../`` segments; once promoted to the root it + needs ``d``. Depth 0 has no segments left, so it becomes ``./``. + """ + old_prefix = "../" * (depth + 1) + new_prefix = "../" * depth if depth else "./" + return [ + ("%s%s/" % (old_prefix, version), "%s%s/" % (new_prefix, version)) + for version in version_dirs + ] + + +def fix_links(html_dir, version_dirs, latest): + """Rewrite version-switcher links in the promoted copy at the root. + + Walks only the files that were promoted -- the version directories + themselves are correct as built and must not be touched. + """ + n_files = 0 + n_edits = 0 + + for dirpath, dirnames, filenames in os.walk(html_dir): + rel_dir = os.path.relpath(dirpath, html_dir) + if rel_dir == ".": + # Don't descend into the version directories; their links are + # already right, and rewriting them would break them. + dirnames[:] = [d for d in dirnames if d not in version_dirs] + depth = 0 + else: + depth = len(rel_dir.split(os.sep)) + + replacements = build_replacements(version_dirs, depth) + + for filename in filenames: + if not filename.endswith(".html"): + continue + path = os.path.join(dirpath, filename) + with open(path, "r", encoding="utf-8") as f: + text = f.read() + + original = text + for old, new in replacements: + text = text.replace(old, new) + + if text != original: + with open(path, "w", encoding="utf-8") as f: + f.write(text) + n_edits += 1 + n_files += 1 + + print( + "Rewrote version links in %d of %d promoted HTML file(s) " + "(promoted version: %s)" % (n_edits, n_files, latest) + ) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument( + "html_dir", + nargs="?", + default=os.path.join(DOC_DIR, "_build", "html"), + help="multi-version build output directory " + "(default: doc/_build/html)", + ) + parser.add_argument( + "--conf", + default=os.path.join(DOC_DIR, "conf.py"), + help="path to conf.py, read for smv_latest_version " + "(default: doc/conf.py)", + ) + args = parser.parse_args(argv) + + html_dir = os.path.abspath(args.html_dir) + if not os.path.isdir(html_dir): + sys.exit( + "%s does not exist; run `make versions` first." % html_dir + ) + + latest = read_latest_version(args.conf) + version_dirs = find_version_dirs(html_dir) + + if not version_dirs: + sys.exit( + "%s contains no version directories; `make versions` did not " + "produce a usable build." % html_dir + ) + + if latest not in version_dirs: + sys.exit( + "smv_latest_version is %r but %s contains only %s.\n" + "Either the whitelists in conf.py exclude the latest version, or " + "smv_latest_version is stale." + % (latest, html_dir, ", ".join(version_dirs)) + ) + + if not os.path.isfile(os.path.join(html_dir, latest, "index.html")): + sys.exit( + "%s has no index.html; refusing to promote an incomplete build." + % os.path.join(html_dir, latest) + ) + + print("Promoting %s to the root of %s" % (latest, html_dir)) + print("Versions in this build: %s" % ", ".join(version_dirs)) + + copy_to_root(html_dir, latest) + fix_links(html_dir, version_dirs, latest) + + if not os.path.isfile(os.path.join(html_dir, "index.html")): + sys.exit("promotion did not produce %s/index.html" % html_dir) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/doc/tools/requirements-docs.txt b/doc/tools/requirements-docs.txt index dc50ca1..a744daa 100644 --- a/doc/tools/requirements-docs.txt +++ b/doc/tools/requirements-docs.txt @@ -1,14 +1,61 @@ -# Python toolchain for building the main Clawpack documentation -# (doc/doc). The clawpack packages themselves are installed separately -# (see .github/workflows/docs.yml); this file pins only the Sphinx tooling -# so that the warning baseline (tools/doc_warnings_baseline.txt) and CI agree. +# Python environment for building the main Clawpack documentation (doc/doc). # -# Regenerate the baseline in an environment built from this file: +# Everything here is pinned exactly. The committed warning baseline +# (tools/doc_warnings_baseline.txt) records the warnings a *specific* toolchain +# emits over a *specific* Clawpack source tree; loose bounds would let an +# upstream release turn CI red on its own, with no change on our side. The +# source tree side of that pair is tools/clawpack-ref.txt. +# +# To reproduce the CI environment exactly: +# +# python -m venv .venv && . .venv/bin/activate # pip install -r tools/requirements-docs.txt -# make checkwarnings-update +# make claw-pin +# CLAW=$(cd ../.claw-pin && pwd) make checkwarnings +# +# Use a fresh virtualenv. An editable or pip-installed clawpack registers a +# meta-path finder that shadows the pinned source tree regardless of sys.path, +# and autodoc would then document the wrong code. tools/check_doc_env.py +# detects that and says so. +# +# To bump: change a pin, rerun the four commands above, and commit the +# regenerated baseline in the same change. + +# ----------------------------------------------------------------------------- +# Sphinx toolchain +# ----------------------------------------------------------------------------- +# Sphinx is held below 9 by sphinx-multiversion, which is unmaintained (0.2.4, +# 2022). It calls sphinx.config.Config.read(confdir, overrides) positionally, +# but Sphinx 9.0 made `overrides` and `tags` keyword-only and required: +# +# TypeError: Config.read() takes 2 positional arguments but 3 were given # -# Lower bounds reflect versions known to build the docs; bump as needed. +# Verified boundary: compatible through 8.2.3, broken from 9.0.0. Until we +# migrate off sphinx-multiversion, `make versions` cannot run on Sphinx 9. +sphinx==8.2.3 +sphinx-multiversion==0.2.4 # only needed for `make versions` +docutils==0.21.2 -sphinx>=7.0 -sphinx-multiversion>=0.2.4 # only needed for `make versions` -docutils>=0.20 +# ----------------------------------------------------------------------------- +# Imported by the Clawpack source tree at module scope +# ----------------------------------------------------------------------------- +# autodoc imports the modules it documents, so anything a documented module +# imports at import time has to be installed here -- or mocked in conf.py's +# autodoc_mock_imports. A module must be one or the other, never neither: +# otherwise a build succeeds or fails depending on what the person running it +# happens to have installed, and the baseline stops being portable. +# +# tools/check_doc_env.py enforces that rule. If it reports a missing module, +# add it here (light, pure-wheel dependencies) or mock it in conf.py (heavy or +# platform-specific ones such as vtk, gdal, tables). +# +# Not listed, deliberately: utm, pyproj, pooch, rioxarray, dask, vtk, gdal, +# tables, pupynere. Every one of those is imported lazily inside a function +# or a try/except, so it never affects an autodoc import. +numpy==2.5.3 +scipy==1.18.1 +matplotlib==3.11.1 +pandas==3.0.5 # clawpack.geoclaw.met.* +xarray==2026.7.0 # clawpack.geoclaw.netcdf_utils +netCDF4==1.7.4 # clawpack.geoclaw.netcdf_utils, clawpack.pyclaw.fileio.netcdf +h5py==3.16.0 # clawpack.pyclaw.fileio.hdf5 raises without h5py or tables diff --git a/doc/tools/test_check_doc_env.py b/doc/tools/test_check_doc_env.py new file mode 100644 index 0000000..91c37ed --- /dev/null +++ b/doc/tools/test_check_doc_env.py @@ -0,0 +1,190 @@ +""" +Tests for tools/check_doc_env.py. + +Runnable either directly (``python tools/test_check_doc_env.py``) or under +pytest. They build the import failures synthetically -- a module object with +an empty ``__path__``, a temporary directory standing in for ``$CLAW`` -- so +they need neither a Clawpack checkout nor the doc toolchain. + +What they protect: `checkenv` has to say *which* of three unrelated problems +it hit. A maintainer once got the "pin the dependency or mock it" advice for +a stale geoclaw checkout and a file missing from riemann's meson.build, which +is the remedy for neither, and the misdirection cost more than the bugs. +""" + +import os +import sys +import tempfile +import types + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# The module reads $CLAW at import time, so fix it to a known root first. +FAKE_CLAW = tempfile.mkdtemp(prefix='fake-claw-') +os.environ['CLAW'] = FAKE_CLAW + +import check_doc_env as cde # noqa: E402 + + +def _from_import_error(package, attribute): + """The ImportError raised by ``from . import ``. + + This is riemann's shape: ``clawpack.riemann.acoustics_1D_py`` is the + target that fails, while ``euler_mapgrid_3D_constants`` -- imported by the + package's __init__ -- is what is actually missing. + """ + module = types.ModuleType(package) + module.__path__ = [] + sys.modules[package] = module + try: + exec(f'from . import {attribute}', + {'__name__': package, '__package__': package}) + except ImportError as exc: + return exc + finally: + del sys.modules[package] + raise AssertionError('expected an ImportError') + + +def _module_not_found(name): + try: + __import__(name) + except ImportError as exc: + return exc + raise AssertionError(f'expected {name} to be absent') + + +def _fake_clawpack(*subdirs): + """Install a stand-in ``clawpack`` whose __path__ points into $CLAW.""" + module = types.ModuleType('clawpack') + module.__path__ = [os.path.join(FAKE_CLAW, *d.split('/')) for d in subdirs] + for path in module.__path__: + os.makedirs(path, exist_ok=True) + sys.modules['clawpack'] = module + return module + + +def test_missing_name_from_module_not_found(): + """The stale-checkout shape: `No module named 'clawpack.geoclaw.met'`.""" + exc = _module_not_found('clawpack_absent_pkg_for_test') + assert cde._missing_name(exc) == 'clawpack_absent_pkg_for_test' + + +def test_missing_name_from_a_failed_from_import(): + """The riemann shape, where exc.name is only half the answer.""" + exc = _from_import_error('clawpack_fake_riemann', 'euler_mapgrid_3D_constants') + assert cde._missing_name(exc) == ( + 'clawpack_fake_riemann.euler_mapgrid_3D_constants') + + +def test_missing_name_falls_back_to_the_message(): + """Older interpreters set neither name nor name_from.""" + bare = ImportError("cannot import name 'gone' from 'clawpack.riemann'") + assert cde._missing_name(bare) == 'clawpack.riemann.gone' + bare = ImportError("No module named 'clawpack.geoclaw.met'") + assert cde._missing_name(bare) == 'clawpack.geoclaw.met' + + +def test_missing_name_gives_up_quietly(): + assert cde._missing_name(ImportError('something else entirely')) is None + + +def test_source_path_resolves_under_a_broken_parent(): + """riemann again: the parent package is exactly what failed to import. + + _source_path must fall back to clawpack's own __path__ and still find the + file, or the "it is right here on disk" diagnosis is impossible. + """ + _fake_clawpack('riemann', 'geoclaw/src/python') + target = os.path.join(FAKE_CLAW, 'riemann', 'riemann') + os.makedirs(target, exist_ok=True) + expected = os.path.join(target, 'euler_mapgrid_3D_constants.py') + open(expected, 'w').close() + + path, searched = cde._source_path( + 'clawpack.riemann.euler_mapgrid_3D_constants') + assert path == expected + assert searched, 'the searched-paths list is what the report prints' + + +def test_source_path_reports_absence_and_where_it_looked(): + """The stale-checkout case: nothing on disk, and the paths tried.""" + _fake_clawpack('geoclaw/src/python') + path, searched = cde._source_path('clawpack.geoclaw.met.gridded') + assert path is None + assert any(p.endswith(os.path.join('geoclaw', 'met', 'gridded.py')) + for p in searched), searched + + +def test_classify_absent_module(): + _fake_clawpack('geoclaw/src/python') + kind, missing, searched = cde.classify( + ImportError("No module named 'clawpack.geoclaw.met'")) + assert kind == 'absent' + assert missing == 'clawpack.geoclaw.met' + assert isinstance(searched, list) + + +def test_classify_on_disk_but_unexposed(): + """The install-shadows-the-tree case, which needs its own remedy.""" + _fake_clawpack('riemann') + target = os.path.join(FAKE_CLAW, 'riemann', 'riemann') + os.makedirs(target, exist_ok=True) + open(os.path.join(target, 'static.py'), 'w').close() + + kind, missing, path = cde.classify( + ImportError("cannot import name 'static' from 'clawpack.riemann'")) + assert kind == 'unexposed' + assert missing == 'clawpack.riemann.static' + assert path.endswith(os.path.join('riemann', 'riemann', 'static.py')) + + +def test_classify_third_party(): + kind, missing, _ = cde.classify( + ImportError("No module named 'petsc4py'")) + assert kind == 'third_party' + assert missing == 'petsc4py' + + +def test_classify_attribute_error_is_not_a_missing_module(): + """AttributeError also has a `.name`, and it is not a module name.""" + kind, missing, _ = cde.classify( + AttributeError("module 'clawpack.geoclaw.topotools' has no attribute " + "'Topography2'", name='Topography2')) + assert kind == 'attribute' + assert missing == 'Topography2' + + +def test_classify_gives_up_out_loud(): + kind, missing, _ = cde.classify(ValueError('not an import problem')) + assert kind == 'unknown' + assert missing is None + + +def test_shadowing_install_detects_a_mesonpy_finder(): + """The one fact that explains "the file is there but will not import".""" + class MesonpyMetaFinder: + pass + + sys.meta_path.insert(0, MesonpyMetaFinder()) + try: + install = cde._shadowing_install() + finally: + sys.meta_path.pop(0) + assert install is not None + version, finders = install + assert any('meson-python' in f for f in finders), finders + + +if __name__ == '__main__': + failures = 0 + for name, func in sorted(globals().items()): + if name.startswith('test_') and callable(func): + try: + func() + except AssertionError as exc: + failures += 1 + print(f'FAIL {name}: {exc}') + else: + print(f'ok {name}') + sys.exit(1 if failures else 0) diff --git a/doc/tools/test_check_doc_warnings.py b/doc/tools/test_check_doc_warnings.py new file mode 100644 index 0000000..917f329 --- /dev/null +++ b/doc/tools/test_check_doc_warnings.py @@ -0,0 +1,143 @@ +""" +Tests for tools/check_doc_warnings.py. + +Runnable either directly (``python tools/test_check_doc_warnings.py``) or under +pytest. These feed synthetic Sphinx warning lines through the signature +normaliser rather than running Sphinx, so they are fast and need none of the +doc toolchain. + +What they protect: a warning signature has to mean the same thing on a +developer's laptop and on a CI runner, or the committed baseline silently +becomes a list of one machine's warnings. The real failures that motivated +each case are named in the individual tests. +""" + +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# The module reads $CLAW at import time, so fix it to a known root first. +FAKE_CLAW = '/fake/claw' +os.environ['CLAW'] = FAKE_CLAW + +import check_doc_warnings as cdw # noqa: E402 + + +def signature(line): + """The signature check_doc_warnings would record for a raw warning line.""" + match = cdw._WARNING_RE.match(line) + assert match is not None, f'no warning matched in {line!r}' + return cdw._signature(match) + + +def test_rst_location_is_relative_to_claw(): + assert signature( + f'{FAKE_CLAW}/doc/doc/topo.rst:123: WARNING: undefined label: x' + ) == 'doc/doc/topo.rst: WARNING: undefined label: x' + + +def test_doc_paths_do_not_depend_on_where_claw_points(): + """$CLAW can be a pinned tree far away from the doc repository. + + Both spellings of the same .rst file -- inside $CLAW (the default layout) + and inside this repository (the pinned-tree layout) -- must produce one + signature, or switching to `make claw-pin` would rewrite the baseline. + """ + inside_claw = signature( + f'{FAKE_CLAW}/doc/doc/topo.rst:1: WARNING: undefined label: x') + in_repo = signature( + f'{cdw.SRC_DIR}/topo.rst:1: WARNING: undefined label: x') + assert inside_claw == in_repo == 'doc/doc/topo.rst: WARNING: undefined label: x' + + +def test_line_numbers_are_dropped(): + """Editing a file above a warning must not churn the baseline.""" + a = signature(f'{FAKE_CLAW}/doc/doc/topo.rst:12: WARNING: undefined label: x') + b = signature(f'{FAKE_CLAW}/doc/doc/topo.rst:900: WARNING: undefined label: x') + assert a == b + + +def test_docstring_location_drops_the_file(): + """The same docstring warning, from a source tree and from site-packages. + + Where the module file lives depends only on how clawpack was made + importable; the dotted name is the same object either way. + """ + from_source = signature( + f'{FAKE_CLAW}/geoclaw/src/python/geoclaw/util.py:docstring of ' + 'clawpack.geoclaw.util.bearing:7: ERROR: Unexpected indentation.') + from_install = signature( + '/opt/hostedtoolcache/Python/3.12.14/x64/lib/python3.12/site-packages/' + 'clawpack/geoclaw/util.py:docstring of ' + 'clawpack.geoclaw.util.bearing:7: ERROR: Unexpected indentation.') + assert from_source == from_install + assert from_source == ('docstring of clawpack.geoclaw.util.bearing: ' + 'ERROR: Unexpected indentation.') + + +def test_absolute_paths_inside_messages_are_scrubbed(): + """The failure that produced two spurious "new" warnings on every CI run. + + Sphinx names the other end of a duplicate-label conflict by absolute path, + which _normalize_location never saw because it is in the message. + """ + local = signature( + f'{FAKE_CLAW}/doc/doc/pyclaw/about.rst:4: WARNING: duplicate label ' + f'about, other instance in {FAKE_CLAW}/doc/doc/about.rst') + assert local == ('doc/doc/pyclaw/about.rst: WARNING: duplicate label ' + 'about, other instance in doc/doc/about.rst') + assert FAKE_CLAW not in local + + +def test_message_paths_outside_claw_are_left_alone(): + """Scrubbing must not mangle paths that are part of the message's meaning.""" + sig = signature('WARNING: image file not readable: /etc/nonexistent.png') + assert '/etc/nonexistent.png' in sig + + +def test_locationless_warnings_keep_their_shape(): + assert signature( + "WARNING: A mocked object is detected: 'clawpack.petclaw.state.State' " + '[autodoc.mocked_object]' + ).startswith('WARNING: A mocked object is detected:') + + +def test_autodoc_import_failures_are_always_fatal(): + sig = ("WARNING: autodoc: failed to import module 'topotools' from module " + "'clawpack.geoclaw'; the following exception was raised:") + assert cdw._always_fail(sig) is not None + assert cdw._always_fail('doc/doc/topo.rst: WARNING: undefined label: x') is None + + +def test_update_refuses_to_record_always_fail_warnings(): + """--update must not be able to bless an environment failure into silence.""" + keep = 'doc/doc/topo.rst: WARNING: undefined label: x' + drop = ("WARNING: autodoc: failed to import module 'topotools' from " + "module 'clawpack.geoclaw'; the following exception was raised:") + + with tempfile.TemporaryDirectory() as tmp: + original = cdw.BASELINE + cdw.BASELINE = os.path.join(tmp, 'baseline.txt') + try: + cdw.write_baseline({keep, drop}) + written = cdw.load_baseline() + finally: + cdw.BASELINE = original + + assert keep in written + assert drop not in written + + +def main(): + tests = [v for k, v in sorted(globals().items()) if k.startswith('test_')] + for test in tests: + test() + print(f'ok {test.__name__}') + print(f'\n{len(tests)} test(s) passed.') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/doc/tools/test_check_published_tree.sh b/doc/tools/test_check_published_tree.sh new file mode 100755 index 0000000..f79d76e --- /dev/null +++ b/doc/tools/test_check_published_tree.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# +# Tests for check_published_tree.sh. +# +# Usage: doc/tools/test_check_published_tree.sh +# +# The negative cases matter more than the positive one: this guard exists to +# stop a publish that would delete parts of www.clawpack.org that no build can +# regenerate, so the tests assert that it actually refuses. + +set -uo pipefail + +HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +GUARD=$HERE/check_published_tree.sh + +VERSIONS="dev v5.14.x v5.13.x v5.12.x v5.11.x v5.10.x v5.9.x v5.8.x v5.7.x" + +# Top-level paths the published site has but no build produces. A subset of +# the real list is enough to exercise the logic. +UNMANAGED_DIRS="gallery doxygen notebooks pdf v5.1.x v5.6.x" +UNMANAGED_FILES="README.txt clawlogo.jpg index_redirect.html" + +n_pass=0 +n_fail=0 + +report() { + if [ "$1" = "pass" ]; then + n_pass=$((n_pass + 1)); echo "PASS $2" + else + n_fail=$((n_fail + 1)); echo "FAIL $2" + fi +} + +# expect_ok -- guard must succeed +expect_ok() { + local name=$1; shift + if "$@" >/dev/null 2>&1; then report pass "$name"; else + report fail "$name (guard rejected a safe tree)" + "$@" 2>&1 | tail -5 | sed 's/^/ /' + fi +} + +# expect_fail -- guard must refuse +expect_fail() { + local name=$1 want=$2; shift 2 + local out + if out=$("$@" 2>&1); then + report fail "$name (guard ALLOWED an unsafe tree)" + elif ! echo "$out" | grep -q "$want"; then + report fail "$name (refused, but not for the expected reason)" + echo "$out" | tail -5 | sed 's/^/ /' + else + report pass "$name" + fi +} + +# Build a fake promoted multiversion build. +make_build() { + local build=$1 + mkdir -p "$build" + for v in $VERSIONS; do + mkdir -p "$build/$v" + echo "dev" > "$build/$v/index.html" + done + # Promoted root: links already rewritten to ./ + echo "dev" > "$build/index.html" + echo "www.clawpack.org" > "$build/CNAME" + : > "$build/.nojekyll" + mkdir -p "$build/_static" + echo "body{}" > "$build/_static/style.css" +} + +# Build a fake clawpack.github.com clone, committed. +make_site() { + local site=$1 + mkdir -p "$site" + git -C "$site" init --quiet + git -C "$site" config user.email t@example.com + git -C "$site" config user.name test + + for v in $VERSIONS; do + mkdir -p "$site/$v" + echo "dev" > "$site/$v/index.html" + done + for d in $UNMANAGED_DIRS; do + mkdir -p "$site/$d" + echo "hand-maintained content" > "$site/$d/index.html" + done + for f in $UNMANAGED_FILES; do + echo "hand-maintained" > "$site/$f" + done + mkdir -p "$site/pyclaw/gallery" + echo "pyclaw gallery" > "$site/pyclaw/gallery/gallery_all.html" + + echo "dev" > "$site/index.html" + echo "www.clawpack.org" > "$site/CNAME" + : > "$site/.nojekyll" + + git -C "$site" add -A + git -C "$site" commit --quiet -m "initial site" +} + +with_fixture() { + ROOT=$(mktemp -d) + BUILD=$ROOT/site-build + SITE=$ROOT/site + make_build "$BUILD" + make_site "$SITE" +} + +cleanup() { rm -rf "$ROOT"; } + +# --------------------------------------------------------------------------- +echo "== the additive sync the workflow actually performs ==" +with_fixture +rsync -a "$BUILD"/dev/ "$SITE"/dev/ +rsync -a --exclude='/dev/' --exclude='/v*.*.x/' "$BUILD"/ "$SITE"/ +expect_ok "additive sync is accepted" "$GUARD" "$SITE" "$BUILD" +cleanup + +# --------------------------------------------------------------------------- +echo +echo "== the failure this guard exists to prevent ==" +# A bare `rsync -a --delete` at the site root does not just remove published +# content -- it removes .git along with it, destroying the repository. The +# guard cannot even inspect the result, which is itself a refusal. +with_fixture +rsync -a --delete "$BUILD"/ "$SITE"/ +expect_fail "root --delete (which also eats .git) is refused" \ + "not a git clone" "$GUARD" "$SITE" "$BUILD" +cleanup + +# The more realistic careless case: --delete with .git spared, so the repo +# survives and the damage shows up as staged deletions. +with_fixture +rsync -a --delete --exclude='/.git/' "$BUILD"/ "$SITE"/ +expect_fail "root --delete is refused" "would be deleted" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +with_fixture +rsync -a --delete --exclude='/.git/' "$BUILD"/ "$SITE"/ +# ...and --prune must not rescue it: the deletions are outside version dirs. +expect_fail "root --delete is refused even with --prune" \ + "only inside the version dirs" \ + "$GUARD" "$SITE" "$BUILD" --prune +cleanup + +# --------------------------------------------------------------------------- +echo +echo "== unmanaged content must not be modified ==" +with_fixture +rsync -a "$BUILD"/dev/ "$SITE"/dev/ +echo "clobbered" > "$SITE/gallery/index.html" +expect_fail "modified unmanaged dir is refused" "was modified by the sync" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +with_fixture +rsync -a "$BUILD"/dev/ "$SITE"/dev/ +echo "clobbered" > "$SITE/README.txt" +expect_fail "modified unmanaged root file is refused" \ + "was modified by the sync" "$GUARD" "$SITE" "$BUILD" +cleanup + +with_fixture +rsync -a "$BUILD"/dev/ "$SITE"/dev/ +echo "clobbered" > "$SITE/pyclaw/gallery/gallery_all.html" +expect_fail "modified pyclaw/gallery is refused" "was modified by the sync" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +# A version directory the build can no longer regenerate is unmanaged too. +with_fixture +rm -rf "$SITE/v5.1.x" +expect_fail "deleting a frozen version dir is refused" "would be deleted" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +# --------------------------------------------------------------------------- +echo +echo "== site invariants ==" +with_fixture +echo "example.com" > "$SITE/CNAME" +expect_fail "wrong CNAME is refused" "CNAME is" "$GUARD" "$SITE" "$BUILD" +cleanup + +# .nojekyll is tracked in the site repo, so losing it trips the deletion +# check first. Either way the publish is refused, which is what matters -- +# without it Pages stops serving _static and every page loses its CSS. +with_fixture +rm "$SITE/.nojekyll" +expect_fail "missing .nojekyll is refused" "would be deleted" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +# The invariant check itself, reached when the file was never tracked. +with_fixture +git -C "$SITE" rm --quiet --cached .nojekyll +git -C "$SITE" commit --quiet -m "untrack .nojekyll" +rm "$SITE/.nojekyll" +expect_fail "absent untracked .nojekyll is refused" ".nojekyll missing" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +# --------------------------------------------------------------------------- +echo +echo "== an unpromoted build must not reach the site ==" +with_fixture +# `make versions` without the promote step: root links keep the extra ../ +echo "dev" > "$SITE/index.html" +expect_fail "unpromoted root is refused" "unpromoted build" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +# --------------------------------------------------------------------------- +echo +echo "== a partial build must not reach the site ==" +with_fixture +rm -rf "$BUILD"/v5.7.x "$BUILD"/v5.8.x "$BUILD"/v5.9.x +rsync -a "$BUILD"/dev/ "$SITE"/dev/ +expect_fail "too few versions is refused" "expected >=" \ + "$GUARD" "$SITE" "$BUILD" +cleanup + +# --------------------------------------------------------------------------- +echo +echo "== --prune inside a rebuilt version dir is allowed ==" +with_fixture +echo "stale page" > "$SITE/dev/removed_page.html" +git -C "$SITE" add -A +git -C "$SITE" commit --quiet -m "add a stale page" +rsync -a --delete "$BUILD"/dev/ "$SITE"/dev/ +expect_ok "prune inside dev/ is accepted" \ + "$GUARD" "$SITE" "$BUILD" --prune +cleanup + +echo +echo "$n_pass passed, $n_fail failed" +[ "$n_fail" -eq 0 ] diff --git a/doc/tools/test_promote_latest.py b/doc/tools/test_promote_latest.py new file mode 100644 index 0000000..af9797d --- /dev/null +++ b/doc/tools/test_promote_latest.py @@ -0,0 +1,187 @@ +""" +Tests for tools/promote_latest.py. + +Runnable either directly (``python tools/test_promote_latest.py``) or under +pytest. These build a synthetic multi-version tree rather than invoking +Sphinx, so they are fast and have no dependency on the doc toolchain. + +The link shapes asserted here come from +``sphinx_multiversion.sphinx.VersionInfo.vpathto``, which emits a switcher +link with ``depth + 1`` leading ``../`` segments for a page at ``depth`` +inside its version directory. +""" + +import os +import shutil +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import promote_latest # noqa: E402 + + +def build_tree(root): + """Create a synthetic sphinx-multiversion output tree under *root*.""" + html = os.path.join(root, "html") + for sub in ( + "v5.14.x/pyclaw/evolve", + "v5.14.x/_static", + "dev/pyclaw", + "v5.12.x", + ): + os.makedirs(os.path.join(html, sub)) + + # Depth 0 page in the version being promoted. The last link is a decoy: + # it starts with "../v5" but is not a version directory, so the old + # substring-based rewrite would have corrupted it. + depth0 = ( + 'dev\n' + 'v5.12.x\n' + 'css\n' + 'not a version\n' + ) + for name in ("index.html", "about.html"): + with open(os.path.join(html, "v5.14.x", name), "w") as f: + f.write(depth0) + + depth1 = ( + 'dev\n' + 'v5.12.x\n' + 'css\n' + ) + with open(os.path.join(html, "v5.14.x", "pyclaw", "about.html"), "w") as f: + f.write(depth1) + + # Depth 2 -- the case fix_links_top_level.py never reached. + depth2 = 'dev\n' + with open( + os.path.join(html, "v5.14.x", "pyclaw", "evolve", "limiters.html"), "w" + ) as f: + f.write(depth2) + + # Dotfiles that `cp -r /*` would have skipped. + open(os.path.join(html, "v5.14.x", ".nojekyll"), "w").close() + with open(os.path.join(html, "v5.14.x", "CNAME"), "w") as f: + f.write("www.clawpack.org\n") + + with open(os.path.join(html, "dev", "index.html"), "w") as f: + f.write('latest\n') + with open(os.path.join(html, "v5.12.x", "index.html"), "w") as f: + f.write("older\n") + + conf = os.path.join(root, "conf.py") + with open(conf, "w") as f: + f.write("smv_latest_version = 'v5.14.x'\n") + + return html, conf + + +def read(*parts): + with open(os.path.join(*parts)) as f: + return f.read() + + +def test_promote_rewrites_links_at_every_depth(): + root = tempfile.mkdtemp() + try: + html, conf = build_tree(root) + promote_latest.main([html, "--conf", conf]) + + # Depth 0: one ../ segment removed, so ./ remains. + top = read(html, "index.html") + assert '' in top + assert '' in top + # A same-directory asset link must be untouched... + assert '' in top + # ...and so must a non-version path that merely looks like one. + assert '' in top + + # Depth 1: two segments become one. + d1 = read(html, "pyclaw", "about.html") + assert '' in d1 + assert '' in d1 + assert '' in d1 + + # Depth 2: three become two. This is the regression the old script + # left on the live site. + d2 = read(html, "pyclaw", "evolve", "limiters.html") + assert '' in d2 + finally: + shutil.rmtree(root) + + +def test_promote_carries_dotfiles(): + root = tempfile.mkdtemp() + try: + html, conf = build_tree(root) + promote_latest.main([html, "--conf", conf]) + + # Without these GitHub Pages would not serve _static/_sources, and the + # custom domain would be dropped. + assert os.path.isfile(os.path.join(html, ".nojekyll")) + assert read(html, "CNAME").strip() == "www.clawpack.org" + finally: + shutil.rmtree(root) + + +def test_version_directories_are_left_alone(): + root = tempfile.mkdtemp() + try: + html, conf = build_tree(root) + before_latest = read(html, "v5.14.x", "index.html") + before_dev = read(html, "dev", "index.html") + + promote_latest.main([html, "--conf", conf]) + + # The per-version trees are correct as built; rewriting them would + # break the switcher inside each version. + assert read(html, "v5.14.x", "index.html") == before_latest + assert read(html, "dev", "index.html") == before_dev + finally: + shutil.rmtree(root) + + +def test_missing_latest_version_is_an_error(): + root = tempfile.mkdtemp() + try: + html, conf = build_tree(root) + with open(conf, "w") as f: + f.write("smv_latest_version = 'v9.9.x'\n") + + try: + promote_latest.main([html, "--conf", conf]) + except SystemExit as exc: + assert exc.code != 0 + assert "v9.9.x" in str(exc.code) + else: + raise AssertionError("expected a non-zero exit") + finally: + shutil.rmtree(root) + + +def test_empty_build_is_an_error(): + root = tempfile.mkdtemp() + try: + html = os.path.join(root, "html") + os.makedirs(html) + conf = os.path.join(root, "conf.py") + with open(conf, "w") as f: + f.write("smv_latest_version = 'v5.14.x'\n") + + try: + promote_latest.main([html, "--conf", conf]) + except SystemExit as exc: + assert exc.code != 0 + else: + raise AssertionError("expected a non-zero exit") + finally: + shutil.rmtree(root) + + +if __name__ == "__main__": + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for test in tests: + test() + print("ok %s" % test.__name__) + print("\n%d passed" % len(tests)) diff --git a/rsync_doc.sh b/rsync_doc.sh index 7f71c0c..b8bf6b0 100755 --- a/rsync_doc.sh +++ b/rsync_doc.sh @@ -1,2 +1,37 @@ +#!/usr/bin/env bash +# +# Publish the main documentation to a local clone of clawpack.github.com. +# +# Build it first with: +# +# cd doc && make versions-publish +# +# NOTE: `make html` writes doc/_build1/html, which is a single-version scratch +# build with no site root -- it is NOT publishable. The guard below exists to +# catch that mistake, and to catch a `make versions` that was never promoted. +# +# Set DRYRUN=1 to see what would be copied without writing anything. +# +# This is additive on purpose: no --delete. The published site holds many +# directories this build does not produce (gallery/, amrclaw/, geoclaw/, +# doxygen/, notebooks/, pdf/, ...), and deleting them would take down large +# parts of www.clawpack.org. -rsync -azv doc/_build/html/ ../clawpack.github.com/ +set -euo pipefail + +SRC=doc/_build/html +DEST=../clawpack.github.com + +if [ ! -f "$SRC/index.html" ]; then + echo "error: no promoted build found at $SRC/index.html" >&2 + echo " run 'cd doc && make versions-publish' first" >&2 + exit 1 +fi + +if [ ! -d "$DEST" ]; then + echo "error: $DEST does not exist" >&2 + echo " clone clawpack/clawpack.github.com next to this repo" >&2 + exit 1 +fi + +rsync -av ${DRYRUN:+--dry-run} "$SRC"/ "$DEST"/ diff --git a/rsync_gallery.sh b/rsync_gallery.sh index 958290f..2541618 100755 --- a/rsync_gallery.sh +++ b/rsync_gallery.sh @@ -1,2 +1,31 @@ +#!/usr/bin/env bash +# +# Publish the gallery to a local clone of clawpack.github.com. +# +# The gallery is built by hand and is not part of the CI publish pipeline: its +# gallery_*.rst pages and thumbnails are generated by gallery/gallery/gallery.py, +# which first has to run the Clawpack and PyClaw examples (see gallery/README.md). +# So this stays a manual step. +# +# Set DRYRUN=1 to see what would be copied without writing anything. +# +# Additive on purpose: no --delete. See the note in rsync_doc.sh. -rsync -azv gallery/_build/html/ ../clawpack.github.com/gallery/ +set -euo pipefail + +SRC=gallery/_build/html +DEST=../clawpack.github.com/gallery + +if [ ! -f "$SRC/index.html" ]; then + echo "error: no gallery build found at $SRC/index.html" >&2 + echo " run 'cd gallery && make html' first (see gallery/README.md)" >&2 + exit 1 +fi + +if [ ! -d "$DEST" ]; then + echo "error: $DEST does not exist" >&2 + echo " clone clawpack/clawpack.github.com next to this repo" >&2 + exit 1 +fi + +rsync -av ${DRYRUN:+--dry-run} "$SRC"/ "$DEST"/