diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 01885ee..50593b9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,5 +1,18 @@ name: docs +# Builds the documentation site and deploys it to GitHub Pages. +# +# What triggers a deployment (job `deploy`): +# - a push to main that changes one of the paths below, usually the merge of a pull request; +# - a manual run on main: Actions > docs > Run workflow, branch main. +# A pull request, and a manual run on another branch, only build the site. +# +# The Examples page shows pages that the e2e workflow (e2e.yml) generated from the examples. +# Each build takes, for every example, the newest docs- artifact of a run of e2e.yml +# on main. A change to an example, or to what e2e produces, therefore reaches the site with the +# next deployment, not by itself. To deploy after such a change, see the steps at +# `workflow_dispatch` below. + on: push: branches: [main] @@ -19,6 +32,14 @@ on: - "zensical.toml" - "README.md" - ".github/workflows/docs.yml" + # To deploy the site again without a change under the paths above, for example after an + # example changed in benchx_examples: + # 1. Run "e2e examples" by hand on main (Actions > e2e examples > Run workflow), and wait + # until its job for the example has finished: a build takes the example pages from the + # newest e2e runs on main, and a run that is still going has no page to give yet. + # 2. Run this workflow by hand on main (Actions > docs > Run workflow). + # A run on main deploys, like a push to main does. A run on another branch only builds. + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -30,23 +51,82 @@ permissions: jobs: build: runs-on: ubuntu-latest + # actions: read, to find and download the example pages (the first two steps). + permissions: + contents: read + actions: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + # The pages of the examples are made by the e2e workflow, which runs on every push to + # main: the job of each example uploads a docs- artifact. For each example, take + # the newest artifact from a run of e2e.yml on main that was not started by a pull request + # (a pull request from a fork whose branch is called main must not get in). Whether the + # other jobs of that run passed does not matter: an example uploads its page only when + # it succeeded. The run for the push being built is usually still going, so the page + # is typically the one from the push before. Artifacts expire after 14 days; an example + # without an artifact is left out of the site, with a warning. + - name: Download the example pages from the latest e2e runs on main + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + declare -A found + count=0 + runs=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow e2e.yml --branch main \ + --limit 20 --json databaseId,event \ + --jq '.[] | select(.event != "pull_request") | .databaseId' || true) + while IFS= read -r run_id; do + [ -n "$run_id" ] || continue + names=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" \ + --jq '.artifacts[] | select(.expired | not) | .name' || true) + while IFS= read -r name; do + [[ "$name" =~ ^docs-[A-Za-z0-9_-]+$ ]] || continue + [ -z "${found[$name]:-}" ] || continue + if gh run download "$run_id" --repo "$GITHUB_REPOSITORY" \ + --name "$name" --dir "generated/$name"; then + found[$name]="$run_id" + count=$((count + 1)) + echo "$name: from e2e run $run_id" + fi + done <<< "$names" + done <<< "$runs" + if [ "$count" -eq 0 ]; then + echo "::warning::No example pages: no unexpired docs-* artifact in the last 20 e2e runs on main. The site is built without examples." + fi - - uses: astral-sh/setup-uv@v5 + # One directory per example, docs/examples//, with the page .md and + # its assets/. The Examples page lists whatever is there (tools/gen_docs.py). + - name: Assemble the example pages into docs/examples/ + run: | + set -euo pipefail + for artifact in generated/docs-*; do + [ -d "$artifact" ] || continue + example="${artifact##*/docs-}" + case "$example" in + ""|*[!a-zA-Z0-9_-]*) echo "Invalid example name in $artifact" >&2; exit 1 ;; + esac + mkdir -p "docs/examples/$example" + cp -R "$artifact/." "docs/examples/$example/" + done + if [ -d docs/examples ]; then find docs/examples -type f | sort; fi - - name: Generate derived pages (landing page, benchx page, schema reference) + - name: Generate derived pages (landing page, benchx page, schema reference, Examples page) run: uv run --python 3.12 --with-requirements docs/requirements.txt python tools/gen_docs.py - name: Build site run: uv run --python 3.12 --with-requirements docs/requirements.txt zensical build --strict --clean - - uses: actions/upload-pages-artifact@v3 + # Since v4 this leaves hidden files (dotfiles) out of the artifact; the site has none. + # If it ever needs one, pass `include-hidden-files: true` (an input from v5 on). + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site deploy: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest permissions: @@ -56,7 +136,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0f09a55..73c8a38 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -8,7 +8,8 @@ name: e2e examples # arrow builds a small Google Benchmark suite with CMake and compares the builds: # examples/arrow/demo/run.sh # examples/arrow/archery-local/run.sh -# pyperf-suite runs a suite of Python functions with `bx bench`: +# pyperf-suite runs a suite of Python functions with `bx bench`, by executing the +# example's README through benchx_examples' reusable workflow (see the job): # examples/pyperf-suite/run.sh # matmul-params runs one parametrized benchmark, numpy and torch at three sizes, with # `bx bench`; the quick version from the example's README: @@ -20,9 +21,17 @@ name: e2e examples # Nothing is checked. The job fails only if a step fails (a clone, a build, a script that # crashes); the scripts exit 0 even when a revision fails, so read the printed output. # -# Runs on every pull request, and on demand. A run on demand can take another -# benchx_examples branch, tag or commit (examples_ref), which a pull request here cannot: -# the examples live in another repository. +# Runs on every pull request, on every push to main, and on demand. A run on demand can take +# another benchx_examples branch, tag or commit (examples_ref), which a pull request here +# cannot: the examples live in another repository. +# +# The runs on main are what the documentation site shows of the examples: docs.yml takes, for +# each example, the docs- artifact of the newest run on main (see the comment there). +# This workflow deploys nothing itself. To put a change to an example on the site: run this +# workflow by hand on main (Actions > e2e examples > Run workflow), wait until the job of +# the example has finished, then run docs.yml by hand on main (Actions > docs > Run workflow). +# A merge that changes the docs paths deploys the site by itself, with the pages of the runs +# that had finished by then. # # In the numpy-spin job the benchmarks are pinned to two cores (BENCH_CPUS). num_threads.sh # should use 1, 2 and 4 threads, so that the last of them oversubscribes the two cores. The @@ -35,6 +44,8 @@ on: description: "benchx_examples branch, tag or commit to run" default: main pull_request: + push: + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -56,12 +67,12 @@ jobs: CCACHE_MAXSIZE: 500M steps: - name: Check out benchx - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: benchx - name: Clone benchx_examples - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: benchx-dev/benchx_examples ref: ${{ inputs.examples_ref || 'main' }} @@ -81,7 +92,7 @@ jobs: # The key is new for every run, because a cache cannot be overwritten; a run starts # from the most recent one. It is saved at the end, also when a later step fails. - name: Restore the ccache cache - uses: actions/cache/restore@v4 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.CCACHE_DIR }} key: ccache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -90,7 +101,7 @@ jobs: - name: Zero the ccache statistics run: ccache --zero-stats - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true cache-dependency-glob: benchx/uv.lock @@ -164,14 +175,14 @@ jobs: - name: Save the ccache cache if: always() - uses: actions/cache/save@v4 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ env.CCACHE_DIR }} key: ccache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} - name: Upload the output if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: numpy-spin-output path: | @@ -186,18 +197,18 @@ jobs: timeout-minutes: 45 steps: - name: Check out benchx - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: benchx - name: Clone benchx_examples - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: benchx-dev/benchx_examples ref: ${{ inputs.examples_ref || 'main' }} path: benchx_examples - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true cache-dependency-glob: benchx/uv.lock @@ -232,7 +243,7 @@ jobs: - name: Upload the output if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: arrow-output path: | @@ -241,58 +252,24 @@ jobs: if-no-files-found: warn retention-days: 14 + # The example's README is executed by the reusable workflow of benchx_examples, which + # installs this checkout (benchx-source: checkout), runs examples/pyperf-suite/run.sh + # through the README, and uploads three artifacts: logs-pyperf-suite, results-pyperf-suite + # (the example's out/) and docs-pyperf-suite (the generated page, which docs.yml publishes). + # It prints the same "what is under test" lines as the other jobs. + # + # TEMPORARY: benchx_examples PR #16 (branch ci_exec_md of ev-br/benchx_examples) is not + # merged yet, so the workflow and the examples both come from that branch, and the + # `examples_ref` input is not used here. After the merge, use instead: + # uses: benchx-dev/benchx_examples/.github/workflows/reusable-example.yml@main + # with: examples-repository omitted, examples-ref: ${{ inputs.examples_ref || 'main' }} pyperf-suite: - name: pyperf-suite (run.sh) - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Check out benchx - uses: actions/checkout@v4 - with: - path: benchx - - - name: Clone benchx_examples - uses: actions/checkout@v4 - with: - repository: benchx-dev/benchx_examples - ref: ${{ inputs.examples_ref || 'main' }} - path: benchx_examples - - - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: benchx/uv.lock - - # The script runs plain `python` and `bx`, and the workers need pyperf, which the - # `test` extra brings. benchx is installed in editable mode, which is where it reads - # its schemas from. - - name: Install benchx - working-directory: benchx - run: | - uv python install 3.13 - uv sync --extra test --python 3.13 - echo "$PWD/.venv/bin" >> "$GITHUB_PATH" - - - name: Show what is under test - run: | - echo "python: $(python --version) ($(command -v python))" - echo "bx: $(command -v bx)" - echo "pyperf: $(python -c 'import pyperf; print(pyperf.__version__)')" - echo "benchx: $(git -C benchx rev-parse --short HEAD)" - echo "benchx_examples: $(git -C benchx_examples rev-parse --short HEAD)" - - - name: Run the pyperf suite - working-directory: benchx_examples/examples/pyperf-suite - run: bash ./run.sh - - - name: Upload the output - if: always() - uses: actions/upload-artifact@v4 - with: - name: pyperf-suite-output - path: benchx_examples/examples/pyperf-suite/out - if-no-files-found: warn - retention-days: 14 + uses: ev-br/benchx_examples/.github/workflows/reusable-example.yml@ci_exec_md + with: + example: pyperf-suite + benchx-source: checkout + examples-repository: ev-br/benchx_examples + examples-ref: ci_exec_md matmul-params: name: matmul-params (quick version) @@ -303,18 +280,18 @@ jobs: BENCHX_HOME: ${{ github.workspace }}/benchx_examples/examples/matmul-params/out/home steps: - name: Check out benchx - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: benchx - name: Clone benchx_examples - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: benchx-dev/benchx_examples ref: ${{ inputs.examples_ref || 'main' }} path: benchx_examples - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true cache-dependency-glob: benchx/uv.lock @@ -354,7 +331,7 @@ jobs: - name: Upload the output if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: matmul-params-output path: benchx_examples/examples/matmul-params/out @@ -367,18 +344,18 @@ jobs: timeout-minutes: 30 steps: - name: Check out benchx - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: path: benchx - name: Clone benchx_examples - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: benchx-dev/benchx_examples ref: ${{ inputs.examples_ref || 'main' }} path: benchx_examples - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true cache-dependency-glob: benchx/uv.lock @@ -410,7 +387,7 @@ jobs: - name: Upload the output if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: numpy-venvs-output path: benchx_examples/examples/numpy-venvs/out diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4369d9d..9180f5e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,9 +35,9 @@ jobs: os: [ubuntu-latest, macos-latest] python-version: ["3.11", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 with: enable-cache: true cache-dependency-glob: uv.lock @@ -59,9 +59,9 @@ jobs: name: wheel / py3.13 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 - name: Build the wheel run: uv build --wheel diff --git a/.gitignore b/.gitignore index ecc3995..344d059 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ dist/ # generated by tools/gen_docs.py docs/index.md docs/schemas/ +# the index is generated by tools/gen_docs.py; the pages of the examples are put in by the +# docs workflow, from the artifacts of the e2e workflow +docs/examples/ diff --git a/docs/examples-intro.md b/docs/examples-intro.md new file mode 100644 index 0000000..6b8107c --- /dev/null +++ b/docs/examples-intro.md @@ -0,0 +1,3 @@ +# Examples + +Worked examples of using benchx, from [benchx_examples](https://github.com/benchx-dev/benchx_examples). Each one is a README that CI runs against the benchx of this repository (the `e2e` workflow), and the output on its page is what that run printed. diff --git a/tools/build_site.py b/tools/build_site.py new file mode 100644 index 0000000..fd5f36e --- /dev/null +++ b/tools/build_site.py @@ -0,0 +1,158 @@ +"""Build the documentation site locally, with the pages of the examples, in one command. + + python tools/build_site.py [PATH] [-e NAME ...] + +PATH is a checkout of benchx_examples that has scripts/execute_markdown.py (it is on the branch +of its pull request #16, not yet on its main). The script + + 1. runs the README of each example with that runner: the examples listed in + PATH/.github/workflows/e2e.yml (the ones CI runs), or those named with -e. This takes + about as long as the examples do, and the numbers are those of this machine; + 2. puts the pages where the site expects them, docs/examples//.md; + 3. runs tools/gen_docs.py, and `zensical build --strict --clean`, as the docs workflow does. + +The HTML is in site/. Open site/index.html, or serve it: `python -m http.server -d site`. + +It builds the site also when there are no examples: without PATH, or when none is found or can +be run, the Examples page says that none was built, and a message says why, before the build +and again at the end. An example that fails is left out in the same way, with an error message. + +docs/examples/ is generated (and gitignored), and it is emptied first, so that the site shows +what this run made and nothing left over from an earlier one. + +The Python that runs this script must have what the examples and the build need: benchx, +pyperf and markdown-it-py (the runner is run with this Python, and the examples run `bx` and +`python` from the PATH), and Zensical (`pip install -r docs/requirements.txt`). + +Exit status: 0 if the site was built, with or without examples; 1 if the build failed, or if +an example failed to run (the site is then still built, without that example). +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = ROOT / "docs" / "examples" +LOGS = ROOT / ".cache" / "examples-logs" # .cache/ is gitignored +NAME = re.compile(r"[A-Za-z0-9_-]+") + + +def zensical() -> str | None: + """The zensical of this Python's environment, else the one on the PATH.""" + beside = Path(sys.executable).parent / "zensical" + return str(beside) if beside.is_file() else shutil.which("zensical") + + +def examples_run_by_ci(checkout: Path, notes: list[str]) -> list[str]: + """The `example:` values in the checkout's e2e workflow, in order.""" + workflow = checkout / ".github" / "workflows" / "e2e.yml" + if not workflow.is_file(): + notes.append(f"{workflow} not found, so there is no list of the examples that CI runs " + "(name them with -e NAME)") + return [] + names = re.findall(r"^\s+example:\s*([A-Za-z0-9_-]+)\s*$", workflow.read_text(), re.M) + if not names: + notes.append(f"no `example:` in {workflow} (name the examples with -e NAME)") + return list(dict.fromkeys(names)) + + +def run_examples(checkout: Path, wanted: list[str]) -> tuple[list[str], list[str], list[str]]: + """Run the examples of the checkout. Returns (names made, notes, failures).""" + made: list[str] = [] + notes: list[str] = [] + failures: list[str] = [] + runner = checkout / "scripts" / "execute_markdown.py" + if not runner.is_file(): + notes.append(f"{runner} not found: it is on the branch of benchx_examples pull request #16, " + "not yet on main") + return made, notes, failures + names = wanted or examples_run_by_ci(checkout, notes) + if not names: + return made, notes, failures + if subprocess.run([sys.executable, "-c", "import markdown_it"], capture_output=True).returncode: + failures.append("markdown-it-py is not installed in this environment, so the examples " + "cannot be run (pip install markdown-it-py pyperf benchx)") + return made, notes, failures + if shutil.which("bx") is None: + notes.append("`bx` is not on the PATH: the examples will probably fail " + "(pip install benchx, or pip install -e . in this repository)") + for name in names: + source = checkout / "examples" / name / "README.md" + if not source.is_file(): + notes.append(f"{source} not found") + continue + print(f"\n=== running examples/{name}/README.md", flush=True) + result = subprocess.run( + [sys.executable, str(runner), "--source", str(source), + "--output", str(EXAMPLES / name / f"{name}.md"), "--logs", str(LOGS / name)], + cwd=checkout, + ) + if result.returncode or not (EXAMPLES / name / f"{name}.md").is_file(): + shutil.rmtree(EXAMPLES / name, ignore_errors=True) + failures.append(f"example {name} failed (logs in {LOGS / name})") + else: + made.append(name) + return made, notes, failures + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Build the documentation site locally, with the pages of the examples " + "(see the top of this file).") + parser.add_argument("checkout", nargs="?", type=Path, + help="a benchx_examples checkout, whose examples are run; without it the site " + "is built with no examples") + parser.add_argument("-e", "--example", action="append", default=[], metavar="NAME", + help="run only this example (repeatable); default: those CI runs") + args = parser.parse_args() + bad = [n for n in args.example if not NAME.fullmatch(n)] + if bad: + parser.error(f"not an example name: {bad[0]!r}") + if args.checkout is not None and not args.checkout.is_dir(): + parser.error(f"{args.checkout} is not a directory") + build = zensical() + if build is None: + print("error: zensical is not installed in this environment: pip install -r docs/requirements.txt", + file=sys.stderr) + return 1 + + if EXAMPLES.exists(): + shutil.rmtree(EXAMPLES) + if args.checkout is None: + made, notes, failures = [], ["no benchx_examples checkout was given"], [] + else: + made, notes, failures = run_examples(args.checkout.resolve(), args.example) + + def report(verb: str) -> None: + """The messages about the examples: before the build, and again at its end.""" + for note in notes: + print(f"note: {note}", file=sys.stderr, flush=True) + for failure in failures: + print(f"error: {failure}", file=sys.stderr, flush=True) + if made: + print(f"{verb} with {len(made)} example(s): {', '.join(made)}", flush=True) + else: + print(f"{verb} WITHOUT examples: the Examples page will say that none was built.", + file=sys.stderr, flush=True) + + print("\n=== building the site", flush=True) + report("Building the site") + status = subprocess.run([sys.executable, str(ROOT / "tools" / "gen_docs.py")], cwd=ROOT).returncode + if not status: + status = subprocess.run([build, "build", "--strict", "--clean"], cwd=ROOT).returncode + if status: + print("error: the site was not built (see above)", file=sys.stderr) + return 1 + print() + report("Built site/") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gen_docs.py b/tools/gen_docs.py index 416951c..1666e61 100644 --- a/tools/gen_docs.py +++ b/tools/gen_docs.py @@ -4,6 +4,11 @@ It writes, into docs/, files that are gitignored and never committed: - index.md, from the repository README (links to docs/... rewritten); +- examples/index.md, the Examples page: the introduction in docs/examples-intro.md (committed), + then a list of the examples whose pages are under docs/examples//.md. The docs + workflow puts those pages there, from the artifacts of the e2e workflow; locally, + tools/build_site.py makes them from a benchx_examples checkout, and without them the page + says that none was built; - schemas/index.md, listing every schema; - schemas/.md, one reference page per schema (newest version); - schemas///schema.json and examples/*.json, so the raw @@ -489,9 +494,87 @@ def rewrite_readme(text: str, strip_prefix: str) -> str: return text +# --------------------------------------------------------------------------- examples index + +EXAMPLES_REPO_URL = "https://github.com/benchx-dev/benchx_examples" + + +def example_pages() -> list[tuple[str, Path]]: + """(name, page) for each docs/examples//.md, sorted by name. + + Not committed: the docs workflow assembles them before this runs, one directory per + example, with the page named like the directory (as benchx_examples' workflow names it). + """ + root = DOCS / "examples" + if not root.is_dir(): + return [] + return [ + (d.name, d / f"{d.name}.md") + for d in sorted(root.iterdir()) + if d.is_dir() and (d / f"{d.name}.md").is_file() + ] + + +def title_and_summary(path: Path, fallback: str) -> tuple[str, str]: + """The first heading of a Markdown page, and its first paragraph as plain text. + + Links are reduced to their text: the summary is shown on another page, where a + relative link would point nowhere. + """ + title, paragraph, in_fence, found = fallback, [], False, False + for line in path.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith(("```", "~~~")): + in_fence = not in_fence + if found and paragraph: + break + continue + if in_fence: + continue + if not found: + if line.startswith("# "): + title, found = stripped[2:].strip(), True + continue + if not stripped: + if paragraph: + break + continue + if stripped.startswith(("#", "|", ">", "- ", "* ", "!")): # not a plain paragraph + break + paragraph.append(stripped) + return title, re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", " ".join(paragraph)) + + +EXAMPLES_INTRO = DOCS / "examples-intro.md" + + +def examples_index(examples: list[tuple[str, Path]]) -> str: + """The Examples page: the introduction, then one entry per example. + + The introduction is the committed Markdown file docs/examples-intro.md (excluded from the + site in zensical.toml, so it is not a page of its own). It goes into docs/examples/index.md + as it is, so a relative link in it is relative to docs/examples/. + """ + if not EXAMPLES_INTRO.is_file(): + raise SystemExit(f"{EXAMPLES_INTRO.relative_to(ROOT)} is missing: it is the introduction " + "of the Examples page") + out = [EXAMPLES_INTRO.read_text().strip(), ""] + if not examples: + out.append("No example was built into this site: the pages come from a run of the `e2e` " + "workflow, and none was available.") + for name, page in examples: + title, summary = title_and_summary(page, name) + source = f"[`examples/{name}`]({EXAMPLES_REPO_URL}/tree/main/examples/{name})" + out.append(f"- [{title}]({name}/{name}.md)" + (f": {summary}" if summary else "") + + f" Source: {source}.") + return "\n".join(out) + + # --------------------------------------------------------------------------- main -GENERATED = ["index.md", "schemas"] # paths under docs/, all gitignored +# Paths under docs/, all gitignored. Not the whole of docs/examples/: its pages are put there +# by the docs workflow, and only the index is generated here. +GENERATED = ["index.md", "schemas", "examples/index.md"] def main() -> None: @@ -515,6 +598,10 @@ def write(rel: str, content: str | bytes) -> None: # Landing page from the repository README. write("index.md", rewrite_readme((ROOT / "README.md").read_text(), "docs/")) + # The Examples page, from whatever pages are under docs/examples/. + examples = example_pages() + write("examples/index.md", examples_index(examples)) + # Schema reference pages and raw documents. pages = discover() write("schemas/index.md", schema_index(pages)) @@ -525,7 +612,8 @@ def write(rel: str, content: str | bytes) -> None: write(f"schemas/{p.name}/{v}/schema.json", (vdir / "schema.json").read_bytes()) for ex in sorted((vdir / "examples").glob("*.json")) if (vdir / "examples").is_dir() else []: write(f"schemas/{p.name}/{v}/examples/{ex.name}", ex.read_bytes()) - print(f"generated {len(pages)} schema pages under {DOCS.relative_to(ROOT)}/") + print(f"generated {len(pages)} schema pages and the Examples page ({len(examples)} examples)" + f" under {DOCS.relative_to(ROOT)}/") if __name__ == "__main__": diff --git a/zensical.toml b/zensical.toml index fe06bbc..b0f5e5a 100644 --- a/zensical.toml +++ b/zensical.toml @@ -1,9 +1,14 @@ # Documentation site, built with Zensical (https://zensical.org). # -# python tools/gen_docs.py # generate docs/index.md and docs/schemas/ +# python tools/gen_docs.py # generate docs/index.md, docs/schemas/ and docs/examples/index.md # zensical serve # preview at http://127.0.0.1:8000 # zensical build --strict --clean # what CI runs # +# python tools/build_site.py [PATH] # everything above in one go, with the pages of the +# # examples run from the benchx_examples checkout PATH; +# # without PATH, or without examples, the Examples page +# # says that none was built (see the script) +# # The generated pages are gitignored; run the generator again after changing # a schema or the README. @@ -20,10 +25,12 @@ docs_dir = "docs" site_dir = "site" # Ablation studies are working notes, not part of the published design; -# requirements.txt is build tooling. +# requirements.txt is build tooling; examples-intro.md is the introduction of the generated +# Examples page (tools/gen_docs.py), not a page of its own. exclude_docs = """ *-ablation.md requirements.txt +examples-intro.md """ nav = [ @@ -73,6 +80,7 @@ nav = [ { "One-off benchmarking" = "user_stories/one-off.md" }, ] }, { "benchx" = "benchx.md" }, + { "Examples" = "examples/index.md" }, { "Releasing" = "releasing.md" }, ]