From aa01b70f714a20cc82d95c56558fb632b4486593 Mon Sep 17 00:00:00 2001 From: Colm Talbot Date: Wed, 21 Oct 2026 13:38:29 -0400 Subject: [PATCH] Offer several bilby versions, with a picker on the page The page now offers bilby 2.8.2 (the default), 2.7.1, 2.6.0 and main, listed in bilby-versions.json (which replaces bilby-commit.txt). The picker in the header, or ?bilby= in the URL, chooses one; switching restarts Python with that version and keeps the open example, with any edits. - scripts/fetch-bilby.sh builds a wheel and extracts the examples for each version (examples//) and writes examples/versions.json. - packages.lock.json locks the bilby group per version (2.6.0 needs dynesty 2.1.5); the other groups are resolved against every version, must agree, and are locked once. LALSuite, the PyPI wheels and the page are shared by all versions. - scripts/test-all.mjs and test-examples.mjs take --bilby; CI tests each version in its own job, and known failures can be listed per version. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pages.yml | 4 +- .github/workflows/tests.yml | 25 +++- LOCAL-ONLY.md | 2 +- PLAN.md | 5 +- README.md | 26 ++-- app.js | 102 +++++++++++++--- bilby-commit.txt | 1 - bilby-versions.json | 10 ++ index.html | 5 + packages.lock.json | 230 ++++++++++++++++++++++++++++++++++- py/bootstrap.py | 18 ++- scripts/adapt_examples.py | 24 ++-- scripts/fetch-bilby.sh | 97 ++++++++++----- scripts/fetch-pypi-wheels.py | 3 +- scripts/lock-packages.mjs | 52 ++++++-- scripts/make_manifest.py | 25 ++-- scripts/test-all.mjs | 91 ++++++++------ scripts/test-examples.mjs | 21 +++- scripts/write_licenses.py | 31 ++--- scripts/write_sizes.py | 3 +- scripts/write_wheel_index.py | 11 +- tests/known-failures.json | 37 ++++-- tests/smoke.spec.mjs | 27 +++- worker.js | 31 +++-- 24 files changed, 687 insertions(+), 194 deletions(-) delete mode 100644 bilby-commit.txt create mode 100644 bilby-versions.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 6af62d8..d87e7b9 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,7 +4,7 @@ # # Nothing the site serves from examples/ or wheels/ is stored in the # repository: the examples and the bilby wheel come from the commit pinned in -# bilby-commit.txt, igwn-segments is rebuilt from its sdist, the LALSuite +# bilby-versions.json, igwn-segments is rebuilt from its sdist, the LALSuite # wheel is downloaded from the GitLab job pinned in lalsuite-wheel.json, and # the PyPI wheels pinned in packages.lock.json are served by the site too. name: Deploy to GitHub Pages @@ -48,7 +48,7 @@ jobs: path: | wheels .cache - key: site-${{ hashFiles('bilby-commit.txt', 'lalsuite-wheel.json', 'packages.lock.json', 'scripts/fetch-bilby.sh', 'scripts/build-igwn-segments.sh') }} + key: site-${{ hashFiles('bilby-versions.json', 'lalsuite-wheel.json', 'packages.lock.json', 'scripts/fetch-bilby.sh', 'scripts/build-igwn-segments.sh') }} - name: Build the code editor (CodeMirror) run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d7e911e..fa5b082 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,5 +1,6 @@ -# Test the examples and the page. Independent of deployment (pages.yml): -# failures here are reported but do not stop the site from deploying. +# Test the examples and the page, in one job per bilby version in +# bilby-versions.json. Independent of deployment (pages.yml): failures here +# are reported but do not stop the site from deploying. name: Tests on: @@ -16,8 +17,23 @@ concurrency: cancel-in-progress: true jobs: + versions: + runs-on: ubuntu-latest + outputs: + names: ${{ steps.names.outputs.names }} + steps: + - uses: actions/checkout@v4 + - id: names + run: echo "names=$(jq -c '[.versions[].name]' bilby-versions.json)" >> "$GITHUB_OUTPUT" + test: + needs: versions + name: test (bilby ${{ matrix.bilby }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bilby: ${{ fromJSON(needs.versions.outputs.names) }} steps: - uses: actions/checkout@v4 @@ -35,7 +51,7 @@ jobs: path: | wheels .cache - key: site-${{ hashFiles('bilby-commit.txt', 'lalsuite-wheel.json', 'packages.lock.json', 'scripts/fetch-bilby.sh', 'scripts/build-igwn-segments.sh') }} + key: site-${{ hashFiles('bilby-versions.json', 'lalsuite-wheel.json', 'packages.lock.json', 'scripts/fetch-bilby.sh', 'scripts/build-igwn-segments.sh') }} - name: Install Node tools and build the code editor run: | @@ -63,9 +79,10 @@ jobs: run: npx playwright install --with-deps chromium - name: Run every supported example in Node (bilby test mode) - run: node scripts/test-all.mjs + run: node scripts/test-all.mjs --bilby "${{ matrix.bilby }}" - name: Smoke-test the assembled site in Chromium run: npx playwright test env: SITE_DIR: _site + BILBY_VERSION: ${{ matrix.bilby }} diff --git a/LOCAL-ONLY.md b/LOCAL-ONLY.md index fa4fd05..7e611f9 100644 --- a/LOCAL-ONLY.md +++ b/LOCAL-ONLY.md @@ -9,7 +9,7 @@ scripts in `scripts/` (run together by `scripts/dev.sh` locally and by | What | Source | |---|---| -| `examples/`, `wheels/bilby-*.whl` | bilby commit pinned in `bilby-commit.txt` (`scripts/fetch-bilby.sh`) | +| `examples/`, `wheels/bilby-*.whl` | bilby commits pinned in `bilby-versions.json` (`scripts/fetch-bilby.sh`) | | `wheels/igwn_segments-*.whl` | igwn-segments sdist from PyPI (`scripts/build-igwn-segments.sh`) | | `wheels/lalsuite-*.whl` | GitLab job artifact pinned with sha256 in `lalsuite-wheel.json` (`scripts/fetch-lalsuite.sh`) | diff --git a/PLAN.md b/PLAN.md index b3c7a7f..3f7a8a2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -60,8 +60,9 @@ Pyodide. Background on the LALSuite Pyodide build is in `NOTES.md`. - **32-bit WebAssembly**: floats are 64-bit, but sizes and numpy's default integer are 32-bit (details in `NOTES.md`). scipy's spectral helper `_fft_helper` is replaced so PSDs do not hit numpy's 2 GB view limit. -- **bilby and examples** come from one pinned commit of bilby-dev/bilby - `main` (`bilby-commit.txt`; `scripts/fetch-bilby.sh --update` moves it). +- **bilby and examples** come from pinned commits of bilby-dev/bilby, one + per version the page offers (`bilby-versions.json`: releases and `main`; + `scripts/fetch-bilby.sh --update` moves `main`). `scripts/adapt_examples.py` edits them for Pyodide (pymultinest → nestle, cpnest → dynesty, `npool=` lines removed) and flags unsupported ones; the page shows both. Neither examples nor wheels are stored in git. diff --git a/README.md b/README.md index ffd8322..443466a 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,26 @@ for background and `LOCAL-ONLY.md` for what is still tied to this machine. ## Where bilby comes from -`bilby-commit.txt` pins one commit of -[bilby-dev/bilby](https://github.com/bilby-dev/bilby). `scripts/fetch-bilby.sh` -takes both the examples (`examples/`, without notebooks) and a bilby wheel -(`wheels/`) from that commit, so the examples always match the bilby that runs -them. Neither is stored in this repository. To move to the current `main`: +The page offers several bilby versions, listed in `bilby-versions.json` +(currently 2.8.2, the default, 2.7.1, 2.6.0 and `main`), each pinned to a +commit of [bilby-dev/bilby](https://github.com/bilby-dev/bilby). The picker +in the header switches between them (it restarts Python and keeps the open +example, with any edits), and `?bilby=` in the URL picks one. +`scripts/fetch-bilby.sh` takes both the examples (`examples//`, without +notebooks) and a bilby wheel (`wheels/`) from each version's commit, so the +examples always match the bilby that runs them. Neither is stored in this +repository. To move `main` to the current head: ```bash scripts/fetch-bilby.sh --update ``` +To add a version, add it to `bilby-versions.json` (with `pins` if it needs +older dependencies than `py/bootstrap.py` asks for, as 2.6.0 does for +dynesty), then run `scripts/fetch-bilby.sh` and `node scripts/lock-packages.mjs`. +Everything else (LALSuite, the other wheels, the page) is shared by all +versions. + ## Other wheels `wheels/` is generated, never committed: @@ -49,12 +59,14 @@ https://colmtalbot.github.io/bilby-browser/. It runs the scripts above to genera npm install npx playwright install chromium # once scripts/dev.sh # once, to generate examples/ and wheels/ -npm test # every supported example in Node (test mode) +npm test # every supported example in Node (test mode), every bilby version +npm test -- --bilby 2.6.0 # one bilby version npm run test:browser # Playwright smoke test of the page python -m unittest tests/test_gwosc_range.py # GWOSC range reads (needs gwpy==4.0.2) ``` -Expected failures are listed with reasons in `tests/known-failures.json`. +Expected failures are listed with reasons in `tests/known-failures.json`, +for every version or for one. CI tests each version in its own job. CI runs both before every deploy. ## Licences diff --git a/app.js b/app.js index 1e85a78..289364d 100644 --- a/app.js +++ b/app.js @@ -15,7 +15,9 @@ const editor = BilbyEditor.createEditor($("code"), { placeholderText: "# A blank editor: bilby, its samplers and LALSuite are available.\n# Ctrl/⌘+Enter to run.", }); let worker = null; -let current = null; // {path, files, original} +let current = null; // {path, version, files, original, header} +let versions = null; // examples/versions.json +let bilby = null; // the bilby version in use: an entry of versions.versions let running = false; // localStorage, which can be unavailable (private windows, blocked storage) @@ -526,7 +528,9 @@ for (const tab of TABS) { // ---- worker --------------------------------------------------------------- function startWorker() { - worker = new Worker(versioned("worker.js")); + const url = new URL(versioned("worker.js"), location.href); + url.searchParams.set("bilby", bilby.name); + worker = new Worker(url.href); worker.ready = false; setRunning(false); setStatus("Starting Python…"); @@ -619,7 +623,8 @@ function run() { announce(`Running ${current.path.split("/").pop()}` + (testMode ? " in test mode" : "")); editor.markErrorLine(null); worker.postMessage({ - type: "run", path: current.path, code: editor.getValue(), files: current.files, testMode, + type: "run", path: current.path, code: editor.getValue(), files: current.files, + filesFrom: current.version, testMode, logLevel: $("log-level").value, uploads: [...uploads].map(([name, { bytes }]) => ({ name, bytes })), }); @@ -627,9 +632,13 @@ function run() { function stop() { if (!running) return; + restartWorker("stopped; restarting Python", "error"); +} + +function restartWorker(message, cls) { worker.terminate(); - append("stopped; restarting Python", "error"); - announce("Stopped; restarting Python"); + append(message, cls); + announce(message[0].toUpperCase() + message.slice(1)); block = output; running = false; workerHasFiles = false; @@ -710,36 +719,43 @@ $("search").addEventListener("keydown", (e) => { } }); -async function select(path) { - if (path === "scratch") return openScratch(); +// Open an example of the bilby version in use. With keepCode, the editor +// keeps what it has (edits made to the same example in another version). +// Returns false if this version has no such example. +async function select(path, { keepCode = false } = {}) { + if (path === "scratch") { + openScratch(); + return true; + } const link = document.querySelector(`#tree a[data-path="${CSS.escape(path)}"]`); - if (!link) return; + if (!link) return false; for (const a of document.querySelectorAll("#tree a.active")) a.classList.remove("active"); link.classList.add("active"); // open the folders containing this example for (let el = link.parentElement; el && el.id !== "tree"; el = el.parentElement) { if (el.tagName === "DETAILS") el.open = true; } - const response = await fetch(versioned("examples/" + path)); + const response = await fetch(versioned(`examples/${bilby.name}/${path}`)); // the shebang and docstring are shown as formatted text above the editor // instead of in it; Download puts them back const lines = (await response.text()).split("\n"); const headerLines = Number(link.dataset.headerLines); const code = lines.slice(headerLines).join("\n"); current = { - path, files: JSON.parse(link.dataset.files), original: code, + path, version: bilby.name, files: JSON.parse(link.dataset.files), original: code, header: headerLines ? lines.slice(0, headerLines).join("\n") + "\n" : "", }; - editor.load(code); + if (!keepCode) editor.load(code); $("title").textContent = path; showDoc(JSON.parse(link.dataset.doc)); showNotes(JSON.parse(link.dataset.info)); + return true; } // A blank editor, not tied to an example; its text is kept in this browser. function openScratch() { for (const a of document.querySelectorAll("#tree a.active")) a.classList.remove("active"); - current = { path: SCRATCH, files: [], original: "", header: "" }; + current = { path: SCRATCH, version: bilby.name, files: [], original: "", header: "" }; editor.load(stored("bilby-scratch") || ""); $("title").textContent = SCRATCH; showDoc(["

A blank editor. Its contents are kept in this browser; Reset clears it.

"]); @@ -794,18 +810,65 @@ function showNotes({ changes, support }) { } } -async function main() { - const manifest = await (await fetch(versioned("examples/manifest.json"))).json(); +// ---- bilby versions ------------------------------------------------------ +// The page offers each version in examples/versions.json; ?bilby= in +// its URL picks one (the default otherwise). Switching restarts Python with +// that version and keeps the example open, with any edits. + +const DEFAULT_EXAMPLE = "core_examples/linear_regression.py"; + +// Show the examples of this version (bilby and its tree; not the worker). +async function showVersion(name) { + bilby = versions.versions.find((v) => v.name === name); + $("bilby-version").value = name; + $("bilby-version").title = `bilby ${bilby.version}: the version the examples come from and run with`; + const manifest = await (await fetch(versioned(`examples/${name}/manifest.json`))).json(); const tree = document.createElement("div"); + tree.id = "examples"; buildTree(manifest, tree); + $("examples")?.remove(); $("tree").insertBefore(tree, $("tree").querySelector(".links")); // links stay last (phones) - if (manifest.commit) { - const link = $("source"); - link.href = `https://github.com/bilby-dev/bilby/tree/${manifest.commit}/examples`; - link.textContent = `examples from bilby@${manifest.commit.slice(0, 8)}`; + if ($("search").value) filterTree($("search").value); + const link = $("source"); + link.href = `https://github.com/bilby-dev/bilby/tree/${bilby.tag || bilby.commit}/examples`; + link.textContent = bilby.tag ? `examples from bilby ${bilby.tag}` : `examples from bilby@${bilby.commit.slice(0, 8)}`; +} + +async function switchVersion(name) { + const url = new URL(location.href); + if (name === versions.default) url.searchParams.delete("bilby"); + else url.searchParams.set("bilby", name); + history.replaceState(null, "", url); + const before = current; + const edited = editor.getValue() !== before.original; + await showVersion(name); + const label = bilby.version === name ? name : `${name} (${bilby.version})`; + restartWorker(`switched to bilby ${label}; restarting Python`, "info"); + if (before.path === SCRATCH) return; + if (await select(before.path, { keepCode: edited })) { + if (edited) append(`kept your edits to ${before.path}; Reset gives bilby ${name}'s version`, "info"); + } else { + // not an example in this version: keep it (and its data files) as it was + current = before; + append(`${before.path} is not one of bilby ${name}'s examples; it stays open, from bilby ${before.version}`, "info"); } +} + +async function main() { + versions = await (await fetch(versioned("examples/versions.json"))).json(); + const picker = $("bilby-version"); + for (const v of versions.versions) picker.add(new Option(v.name, v.name)); + const wanted = new URLSearchParams(location.search).get("bilby"); + await showVersion(versions.versions.some((v) => v.name === wanted) ? wanted : versions.default); + startWorker(); + picker.addEventListener("change", () => switchVersion(picker.value)); window.addEventListener("hashchange", () => select(decodeURIComponent(location.hash.slice(1)))); - await select(decodeURIComponent(location.hash.slice(1)) || "core_examples/linear_regression.py"); + const path = decodeURIComponent(location.hash.slice(1)); + if (!(await select(path || DEFAULT_EXAMPLE)) && path) { + append(`${path} is not one of bilby ${bilby.name}'s examples`, "info"); + history.replaceState(null, "", "#" + DEFAULT_EXAMPLE); + await select(DEFAULT_EXAMPLE); + } } // bilby test mode: on by default when served locally (development), off on @@ -1020,5 +1083,4 @@ $("theme").addEventListener("click", () => { }); $("reset").addEventListener("click", () => { if (current) editor.reset(current.original); }); -startWorker(); main(); diff --git a/bilby-commit.txt b/bilby-commit.txt deleted file mode 100644 index d167d32..0000000 --- a/bilby-commit.txt +++ /dev/null @@ -1 +0,0 @@ -f2d0363470410172aae8cbbe6452fcd3a1d1f41c diff --git a/bilby-versions.json b/bilby-versions.json new file mode 100644 index 0000000..7f616c9 --- /dev/null +++ b/bilby-versions.json @@ -0,0 +1,10 @@ +{ + "_comment": "The bilby versions the site offers, in the order of the page's picker. Each is pinned to a commit of github.com/bilby-dev/bilby; releases also name their tag. pins replace the matching specs of the bilby group in py/bootstrap.py when scripts/lock-packages.mjs resolves that version. scripts/fetch-bilby.sh --update moves main to the current head.", + "default": "2.8.2", + "versions": [ + {"name": "2.8.2", "tag": "v2.8.2", "commit": "9d0e91e31abfe0f716152bb06d2b496783520add"}, + {"name": "2.7.1", "tag": "v2.7.1", "commit": "710e1808741c55245d4fcc2b5603f7b2f15e5ae5"}, + {"name": "2.6.0", "tag": "v2.6.0", "commit": "80963a7b46473f9a1ccef839444a3d686da2ad2d", "pins": ["dynesty==2.1.5"]}, + {"name": "main", "commit": "f2d0363470410172aae8cbbe6452fcd3a1d1f41c"} + ] +} diff --git a/index.html b/index.html index 21aaccd..e5b7a3f 100644 --- a/index.html +++ b/index.html @@ -318,6 +318,7 @@ #status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .toggle, .log-level { flex: none; } .log-level > span { display: none; } + nav .links .bilby-version > span { display: inline; } .log-level select { font-size: 12px; padding: 2px 0; } main, body.tree-hidden main, body.editor-hidden main, body.output-hidden main { grid-template-columns: minmax(0, 1fr); padding: 10px 12px; gap: 12px; } nav, body.tree-hidden nav { display: flex; position: fixed; z-index: 30; top: 0; bottom: 0; left: 0; @@ -382,6 +383,10 @@

Bilby + Site source Licences diff --git a/packages.lock.json b/packages.lock.json index c0e0e87..466c16b 100644 --- a/packages.lock.json +++ b/packages.lock.json @@ -1,8 +1,230 @@ { "pyodide_version": "0.29.5", "generated_by": "scripts/lock-packages.mjs", - "groups": { - "bilby": { + "bilby": { + "2.8.2": { + "pyodide": [ + "attrs", + "bilby-cython", + "contourpy", + "cycler", + "fonttools", + "h5py", + "kiwisolver", + "matplotlib", + "micropip", + "numpy", + "packaging", + "pandas", + "pillow", + "pkgconfig", + "pyparsing", + "python-dateutil", + "pytz", + "scipy", + "six", + "tqdm" + ], + "pypi": [ + { + "name": "corner", + "version": "2.3.0", + "file": "corner-2.3.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/d6/cb/17a130e548ee1777f2a66e09240c531a9bf79454add7ef1198fe1e41c412/corner-2.3.0-py3-none-any.whl", + "sha256": "f93f0a4d77ff6852d6ddc80a6a99038117be64e3b5b49d96a82c9f5974cce126", + "license": "BSD 2-Clause License" + }, + { + "name": "dill", + "version": "0.4.1", + "file": "dill-0.4.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", + "sha256": "1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", + "license": "BSD-3-Clause" + }, + { + "name": "dynesty", + "version": "3.1.0", + "file": "dynesty-3.1.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/f1/f9/41c72d0b5fb511fdf23cf62ed50da6c73d4497f03ed1cad04f214dc477c8/dynesty-3.1.0-py3-none-any.whl", + "sha256": "4e5b77bb261abdcb98e3a538d7498a94d5882a848f0ea46924b404396ef036d5", + "license": "MIT" + }, + { + "name": "emcee", + "version": "3.1.6", + "file": "emcee-3.1.6-py2.py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.py3-none-any.whl", + "sha256": "f2d63752023bdccf744461450e512a5b417ae7d28f18e12acd76a33de87580cb", + "license": "MIT" + }, + { + "name": "nestle", + "version": "0.2.1", + "file": "nestle-0.2.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/a6/ed/7b866669511d5a62bed0cc585d403477a453d72c61335cc4fc73f907c220/nestle-0.2.1-py3-none-any.whl", + "sha256": "1189771288bbcbcea782147f05ba60e3edd5c4b6051ae580f9ff3ef753c6f930", + "license": "MIT" + }, + { + "name": "nestle-bilby", + "version": "0.1.1", + "file": "nestle_bilby-0.1.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/84/4e/8e638792152a3c7283baa52d70c7a6206b91a6441ad7e3e1d64c961753b1/nestle_bilby-0.1.1-py3-none-any.whl", + "sha256": "0052240feecd9dba052fee162010fefb4a787e08a9012de529f98f6f5bd3f0bb", + "license": "MIT" + } + ] + }, + "2.7.1": { + "pyodide": [ + "attrs", + "bilby-cython", + "contourpy", + "cycler", + "fonttools", + "h5py", + "kiwisolver", + "matplotlib", + "micropip", + "numpy", + "packaging", + "pandas", + "pillow", + "pkgconfig", + "pyparsing", + "python-dateutil", + "pytz", + "scipy", + "six", + "tqdm" + ], + "pypi": [ + { + "name": "corner", + "version": "2.3.0", + "file": "corner-2.3.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/d6/cb/17a130e548ee1777f2a66e09240c531a9bf79454add7ef1198fe1e41c412/corner-2.3.0-py3-none-any.whl", + "sha256": "f93f0a4d77ff6852d6ddc80a6a99038117be64e3b5b49d96a82c9f5974cce126", + "license": "BSD 2-Clause License" + }, + { + "name": "dill", + "version": "0.4.1", + "file": "dill-0.4.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", + "sha256": "1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", + "license": "BSD-3-Clause" + }, + { + "name": "dynesty", + "version": "3.1.0", + "file": "dynesty-3.1.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/f1/f9/41c72d0b5fb511fdf23cf62ed50da6c73d4497f03ed1cad04f214dc477c8/dynesty-3.1.0-py3-none-any.whl", + "sha256": "4e5b77bb261abdcb98e3a538d7498a94d5882a848f0ea46924b404396ef036d5", + "license": "MIT" + }, + { + "name": "emcee", + "version": "3.1.6", + "file": "emcee-3.1.6-py2.py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.py3-none-any.whl", + "sha256": "f2d63752023bdccf744461450e512a5b417ae7d28f18e12acd76a33de87580cb", + "license": "MIT" + }, + { + "name": "nestle", + "version": "0.2.1", + "file": "nestle-0.2.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/a6/ed/7b866669511d5a62bed0cc585d403477a453d72c61335cc4fc73f907c220/nestle-0.2.1-py3-none-any.whl", + "sha256": "1189771288bbcbcea782147f05ba60e3edd5c4b6051ae580f9ff3ef753c6f930", + "license": "MIT" + }, + { + "name": "nestle-bilby", + "version": "0.1.1", + "file": "nestle_bilby-0.1.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/84/4e/8e638792152a3c7283baa52d70c7a6206b91a6441ad7e3e1d64c961753b1/nestle_bilby-0.1.1-py3-none-any.whl", + "sha256": "0052240feecd9dba052fee162010fefb4a787e08a9012de529f98f6f5bd3f0bb", + "license": "MIT" + } + ] + }, + "2.6.0": { + "pyodide": [ + "attrs", + "bilby-cython", + "contourpy", + "cycler", + "fonttools", + "h5py", + "kiwisolver", + "matplotlib", + "micropip", + "numpy", + "packaging", + "pandas", + "pillow", + "pkgconfig", + "pyparsing", + "python-dateutil", + "pytz", + "scipy", + "six", + "tqdm" + ], + "pypi": [ + { + "name": "corner", + "version": "2.3.0", + "file": "corner-2.3.0-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/d6/cb/17a130e548ee1777f2a66e09240c531a9bf79454add7ef1198fe1e41c412/corner-2.3.0-py3-none-any.whl", + "sha256": "f93f0a4d77ff6852d6ddc80a6a99038117be64e3b5b49d96a82c9f5974cce126", + "license": "BSD 2-Clause License" + }, + { + "name": "dill", + "version": "0.4.1", + "file": "dill-0.4.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", + "sha256": "1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", + "license": "BSD-3-Clause" + }, + { + "name": "dynesty", + "version": "2.1.5", + "file": "dynesty-2.1.5-py2.py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/c8/01/db367a6adfa49d280cda609ad5d94b9b257058b4499c59633e6d15c5e552/dynesty-2.1.5-py2.py3-none-any.whl", + "sha256": "c1d20f300d6e0fb64675f01683d7511b181d2a5ac372295b1fe5f4410670530b", + "license": "MIT" + }, + { + "name": "emcee", + "version": "3.1.6", + "file": "emcee-3.1.6-py2.py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/f9/ef/2196b9bf88ffa1bde45853c72df021fbd07a8fa91a0f59a22d14a050dc04/emcee-3.1.6-py2.py3-none-any.whl", + "sha256": "f2d63752023bdccf744461450e512a5b417ae7d28f18e12acd76a33de87580cb", + "license": "MIT" + }, + { + "name": "nestle", + "version": "0.2.1", + "file": "nestle-0.2.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/a6/ed/7b866669511d5a62bed0cc585d403477a453d72c61335cc4fc73f907c220/nestle-0.2.1-py3-none-any.whl", + "sha256": "1189771288bbcbcea782147f05ba60e3edd5c4b6051ae580f9ff3ef753c6f930", + "license": "MIT" + }, + { + "name": "nestle-bilby", + "version": "0.1.1", + "file": "nestle_bilby-0.1.1-py3-none-any.whl", + "url": "https://files.pythonhosted.org/packages/84/4e/8e638792152a3c7283baa52d70c7a6206b91a6441ad7e3e1d64c961753b1/nestle_bilby-0.1.1-py3-none-any.whl", + "sha256": "0052240feecd9dba052fee162010fefb4a787e08a9012de529f98f6f5bd3f0bb", + "license": "MIT" + } + ] + }, + "main": { "pyodide": [ "attrs", "bilby-cython", @@ -109,7 +331,9 @@ "license": "MIT" } ] - }, + } + }, + "groups": { "lalsuite": { "pyodide": [ "astropy", diff --git a/py/bootstrap.py b/py/bootstrap.py index e0426ea..98a5445 100644 --- a/py/bootstrap.py +++ b/py/bootstrap.py @@ -25,6 +25,11 @@ # depend on PyPI. The local wheels (bilby, LALSuite, igwn-segments; see # wheels/index.json) are installed separately. # +# The "bilby" group is locked once for each bilby version in +# bilby-versions.json, whose "pins" replace the specs here of the same +# packages (an older bilby may need an older dynesty); the other groups are +# the same for every version. +# # bilby-cython is optional for bilby but makes the GW time and geometry # functions faster. nestle-bilby provides the nestle sampler through the # bilby.samplers entry point; it is installed without dependencies because @@ -79,11 +84,12 @@ _installed = set() -def set_lock(lock_json, pypi_base_url, wheel_urls): +def set_lock(lock_json, pypi_base_url, wheel_urls, bilby_version=None): """Install from `lock_json` (the text of packages.lock.json), with its - PyPI wheels under `pypi_base_url`. `wheel_urls` maps wheels/index.json - keys to URLs of the local wheels. Without a lock (only when - scripts/lock-packages.mjs resolves the groups), PyPI is used.""" + PyPI wheels under `pypi_base_url`, and its "bilby" group for + `bilby_version`. `wheel_urls` maps wheels/index.json keys to URLs of the + local wheels (for "bilby", of that version's wheel). Without a lock (only + when scripts/lock-packages.mjs resolves the groups), PyPI is used.""" import json global _lock, _pypi_base_url @@ -95,6 +101,10 @@ def set_lock(lock_json, pypi_base_url, wheel_urls): f"packages.lock.json was made for Pyodide {_lock['pyodide_version']}, " f"but this is Pyodide {pyodide_js.version}. {REPORT}" ) + if _lock: + if bilby_version not in _lock["bilby"]: + raise RuntimeError(f"This site has no package list for bilby {bilby_version}. {REPORT}") + _lock["groups"]["bilby"] = _lock["bilby"][bilby_version] async def install_group(name): diff --git a/scripts/adapt_examples.py b/scripts/adapt_examples.py index 4d560df..350fff7 100644 --- a/scripts/adapt_examples.py +++ b/scripts/adapt_examples.py @@ -1,15 +1,17 @@ -"""Adapt the bilby examples (fresh from scripts/fetch-bilby.sh) to Pyodide. +"""Adapt one version's bilby examples (fresh from scripts/fetch-bilby.sh) to +Pyodide. -Edits the files in examples/ in place and writes examples/adaptations.json, + python3 scripts/adapt_examples.py examples/ + +Edits the files in that directory in place and writes adaptations.json there, which make_manifest.py merges into the manifest so the page can say what was changed and which examples cannot run in the browser. """ import json +import sys from pathlib import Path -ROOT = Path(__file__).resolve().parent.parent / "examples" - # (description, function from source text to new source text) EDITS = [ ( @@ -53,10 +55,10 @@ } -def adapt(): +def adapt(root): adaptations = {} - for path in sorted(ROOT.rglob("*.py")): - key = path.relative_to(ROOT).as_posix() + for path in sorted(root.rglob("*.py")): + key = path.relative_to(root).as_posix() text = path.read_text() changes = [] for description, edit in EDITS: @@ -76,14 +78,14 @@ def adapt(): if entry: adaptations[key] = entry missing = (set(UNSUPPORTED) | set(PARTIAL)) - { - p.relative_to(ROOT).as_posix() for p in ROOT.rglob("*.py") + p.relative_to(root).as_posix() for p in root.rglob("*.py") } for key in sorted(missing): - print(f"warning: {key} is listed in adapt_examples.py but no longer exists") - out = ROOT / "adaptations.json" + print(f"note: {key} is listed in adapt_examples.py but not in {root}") + out = root / "adaptations.json" out.write_text(json.dumps(adaptations, indent=1) + "\n") print(f"adapted {sum('changes' in e for e in adaptations.values())} examples, wrote {out}") if __name__ == "__main__": - adapt() + adapt(Path(sys.argv[1])) diff --git a/scripts/fetch-bilby.sh b/scripts/fetch-bilby.sh index 07050dc..5c575e9 100755 --- a/scripts/fetch-bilby.sh +++ b/scripts/fetch-bilby.sh @@ -1,14 +1,17 @@ #!/usr/bin/env bash -# Get bilby's examples and a bilby wheel from one pinned commit of -# github.com/bilby-dev/bilby (the hash in bilby-commit.txt). +# Get bilby's examples and a bilby wheel for each version in +# bilby-versions.json, each from its pinned commit of +# github.com/bilby-dev/bilby. # -# scripts/fetch-bilby.sh # use the pinned commit +# scripts/fetch-bilby.sh # use the pinned commits # scripts/fetch-bilby.sh --update # pin the current head of main first # -# Writes examples/ (without notebooks, adapted to Pyodide by -# scripts/adapt_examples.py) and wheels/bilby-*.whl. A blobless +# Writes examples// for each version (without notebooks, adapted to +# Pyodide by scripts/adapt_examples.py), wheels/bilby-*.whl, and +# examples/versions.json, which lists the versions for the page. A blobless # clone is kept in .cache/bilby so that setuptools_scm can compute a real -# version (e.g. 2.8.3.devN+g) from the tags. +# version (e.g. 2.8.3.devN+g) from the tags; built wheels are kept in +# .cache/bilby-wheels//. set -euo pipefail cd "$(dirname "$0")/.." @@ -16,33 +19,71 @@ REPO="${BILBY_REPO:-https://github.com/bilby-dev/bilby.git}" CLONE=.cache/bilby if [[ "${1:-}" == "--update" ]]; then - git ls-remote "$REPO" refs/heads/main | cut -f1 > bilby-commit.txt - echo "pinned bilby main at $(cat bilby-commit.txt)" + HEAD="$(git ls-remote "$REPO" refs/heads/main | cut -f1)" + python3 - "$HEAD" <<'EOF' +import json, sys +path = "bilby-versions.json" +config = json.load(open(path)) +next(v for v in config["versions"] if v["name"] == "main")["commit"] = sys.argv[1] +open(path, "w").write(json.dumps(config, indent=1) + "\n") +EOF + echo "pinned bilby main at $HEAD" fi -COMMIT="$(tr -d '[:space:]' < bilby-commit.txt)" if [[ ! -d "$CLONE/.git" ]]; then git clone --quiet --filter=blob:none --no-checkout "$REPO" "$CLONE" fi -git -C "$CLONE" cat-file -e "$COMMIT^{commit}" 2>/dev/null \ - || git -C "$CLONE" fetch --quiet --tags origin -# Examples are re-extracted every time (cheap), so changes to -# adapt_examples.py always apply to a pristine copy. rm -rf examples -git -C "$CLONE" archive --format=tar "$COMMIT" examples | tar -x --exclude='*.ipynb' -f - -find examples -type d -empty -delete -# the examples are bilby's (MIT); keep its licence next to them -git -C "$CLONE" show "$COMMIT:LICENSE.md" > examples/LICENSE.md -echo "$COMMIT" > examples/.commit -python3 scripts/adapt_examples.py - -if ls wheels/bilby-*+g"${COMMIT:0:8}"*.whl >/dev/null 2>&1; then - echo "bilby wheel already built for ${COMMIT:0:8}" - exit 0 -fi -git -C "$CLONE" checkout --quiet --force --detach "$COMMIT" -mkdir -p wheels +mkdir -p examples wheels rm -f wheels/bilby-*.whl -python3 -m pip wheel --quiet --no-deps --wheel-dir wheels "$CLONE" -echo "bilby $(git -C "$CLONE" describe --tags --always) -> $(ls wheels/bilby-*.whl)" +# name, commit and tag ("-" for none) of each version +python3 -c ' +import json +for v in json.load(open("bilby-versions.json"))["versions"]: + print(v["name"], v["commit"], v.get("tag", "-")) +' | while read -r NAME COMMIT TAG; do + git -C "$CLONE" cat-file -e "$COMMIT^{commit}" 2>/dev/null \ + || git -C "$CLONE" fetch --quiet --tags origin + if [[ "$TAG" != "-" && "$(git -C "$CLONE" rev-parse "$TAG^{commit}")" != "$COMMIT" ]]; then + echo "bilby-versions.json: $TAG is not $COMMIT" >&2 + exit 1 + fi + + # Examples are re-extracted every time (cheap), so changes to + # adapt_examples.py always apply to a pristine copy. + DEST="examples/$NAME" + mkdir -p "$DEST" + git -C "$CLONE" archive --format=tar "$COMMIT" examples \ + | tar -x --exclude='*.ipynb' --strip-components=1 -C "$DEST" -f - + find "$DEST" -type d -empty -delete + # the examples are bilby's (MIT); keep its licence next to them + git -C "$CLONE" show "$COMMIT:LICENSE.md" > "$DEST/LICENSE.md" + python3 scripts/adapt_examples.py "$DEST" + + BUILT=".cache/bilby-wheels/$COMMIT" + if ! ls "$BUILT"/bilby-*.whl >/dev/null 2>&1; then + git -C "$CLONE" checkout --quiet --force --detach "$COMMIT" + rm -rf "$BUILT" + python3 -m pip wheel --quiet --no-deps --wheel-dir "$BUILT" "$CLONE" + fi + cp "$BUILT"/bilby-*.whl wheels/ + echo "bilby $NAME: $(cd "$BUILT" && ls bilby-*.whl)" +done + +python3 - <<'EOF' +import json +from pathlib import Path + +config = json.load(open("bilby-versions.json")) +versions = [] +for v in config["versions"]: + (wheel,) = Path(".cache/bilby-wheels", v["commit"]).glob("bilby-*.whl") + versions.append({ + "name": v["name"], "commit": v["commit"], "tag": v.get("tag"), + "version": wheel.name.split("-")[1], "wheel": wheel.name, + }) +out = {"default": config["default"], "versions": versions} +Path("examples/versions.json").write_text(json.dumps(out, indent=1) + "\n") +print("wrote examples/versions.json") +EOF diff --git a/scripts/fetch-pypi-wheels.py b/scripts/fetch-pypi-wheels.py index d12ec2f..41df18d 100644 --- a/scripts/fetch-pypi-wheels.py +++ b/scripts/fetch-pypi-wheels.py @@ -19,7 +19,8 @@ def sha256(path): def main(): lock = json.loads((ROOT / "packages.lock.json").read_text()) - wheels = {w["file"]: w for group in lock["groups"].values() for w in group["pypi"]} + groups = [*lock["bilby"].values(), *lock["groups"].values()] + wheels = {w["file"]: w for group in groups for w in group["pypi"]} OUT.mkdir(parents=True, exist_ok=True) for stale in set(p.name for p in OUT.glob("*.whl")) - set(wheels): (OUT / stale).unlink() diff --git a/scripts/lock-packages.mjs b/scripts/lock-packages.mjs index 9b4e69b..b7ba561 100644 --- a/scripts/lock-packages.mjs +++ b/scripts/lock-packages.mjs @@ -7,21 +7,37 @@ // // Needs the local wheels (scripts/dev.sh, or the fetch/build scripts). // Each group is resolved in a fresh Pyodide with only the bilby group -// installed, so every group is complete on its own. +// installed, so every group is complete on its own. The bilby group is +// locked for each version in bilby-versions.json (with that version's pins); +// the other groups are resolved against every version too, and must come out +// the same, so they are locked once. import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { loadPyodide } from "pyodide"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const LOCAL = { bilby: "bilby-", lalsuite: "lalsuite-", igwn_segments: "igwn_segments-" }; +const LOCAL = { lalsuite: "lalsuite-", igwn_segments: "igwn_segments-" }; +const config = JSON.parse(readFileSync(join(ROOT, "bilby-versions.json"), "utf8")); +const built = JSON.parse(readFileSync(join(ROOT, "examples/versions.json"), "utf8")).versions; -async function freshPyodide() { +// A Pyodide set up to resolve the groups for this entry of bilby-versions.json +async function freshPyodide(version) { const pyodide = await loadPyodide(); pyodide.registerJsModule("_site", { image() {} }); pyodide.runPython(readFileSync(join(ROOT, "py/bootstrap.py"), "utf8")); + // the version's pins replace the bilby group's specs of the same packages + pyodide.runPython(` +import re +_pins = ${JSON.stringify(version.pins ?? [])} +_name = lambda spec: re.split(r"[=<>!~ \\[]", spec)[0].lower() +GROUPS["bilby"]["pypi"] = [s for s in GROUPS["bilby"]["pypi"] if _name(s) not in {_name(p) for p in _pins}] + _pins +`); pyodide.FS.mkdirTree("/tmp/wheels"); - const urls = {}; + const bilbyWheel = built.find((v) => v.name === version.name)?.wheel; + if (!bilbyWheel) throw new Error(`no wheel for bilby ${version.name}; run scripts/fetch-bilby.sh first`); + pyodide.FS.writeFile(`/tmp/wheels/${bilbyWheel}`, readFileSync(join(ROOT, "wheels", bilbyWheel))); + const urls = { bilby: `emfs:/tmp/wheels/${bilbyWheel}` }; for (const [key, prefix] of Object.entries(LOCAL)) { const wheel = readdirSync(join(ROOT, "wheels")).filter((f) => f.startsWith(prefix) && f.endsWith(".whl")).sort().pop(); if (!wheel) throw new Error(`no ${prefix}*.whl in wheels/; run scripts/dev.sh first`); @@ -58,8 +74,8 @@ async function pypiWheel(name, version) { return { name, version, file: wheel.filename, url: wheel.url, sha256: wheel.digests.sha256, license }; } -async function lockGroup(name, base) { - const pyodide = await freshPyodide(); +async function lockGroup(name, version) { + const pyodide = await freshPyodide(version); const install = pyodide.globals.get("install_group"); if (name !== "bilby") await install("bilby"); const before = name === "bilby" ? {} : installed(pyodide); @@ -73,19 +89,29 @@ async function lockGroup(name, base) { pypi.push(await pypiWheel(pkg, info.version)); } pypi.sort((a, b) => a.name.localeCompare(b.name)); - console.log(`${name}: ${pyodidePackages.length} Pyodide packages, ${pypi.length} PyPI wheels`); + console.log(`bilby ${version.name}, ${name}: ${pyodidePackages.length} Pyodide packages, ${pypi.length} PyPI wheels`); return { pyodide: pyodidePackages, pypi, pyodideVersion: pyodide.version }; } +const bilby = {}; const groups = {}; let pyodideVersion; -const names = (await freshPyodide()).globals.get("GROUPS").toJs({ dict_converter: Object.fromEntries }); -for (const name of Object.keys(names)) { - const { pyodideVersion: version, ...group } = await lockGroup(name); - groups[name] = group; - pyodideVersion = version; +const names = Object.keys((await freshPyodide(config.versions[0])).globals.get("GROUPS").toJs({ dict_converter: Object.fromEntries })); +for (const version of config.versions) { + for (const name of names) { + const { pyodideVersion: v, ...group } = await lockGroup(name, version); + pyodideVersion = v; + if (name === "bilby") { + bilby[version.name] = group; + } else if (!(name in groups)) { + groups[name] = group; + } else if (JSON.stringify(group) !== JSON.stringify(groups[name])) { + throw new Error(`the ${name} group resolves differently with bilby ${version.name} than with ` + + `${config.versions[0].name}; lock it per version like the bilby group`); + } + } } -const lock = { pyodide_version: pyodideVersion, generated_by: "scripts/lock-packages.mjs", groups }; +const lock = { pyodide_version: pyodideVersion, generated_by: "scripts/lock-packages.mjs", bilby, groups }; writeFileSync(join(ROOT, "packages.lock.json"), JSON.stringify(lock, null, 1) + "\n"); console.log("wrote packages.lock.json"); process.exit(0); diff --git a/scripts/make_manifest.py b/scripts/make_manifest.py index 64f33db..26a9c50 100644 --- a/scripts/make_manifest.py +++ b/scripts/make_manifest.py @@ -1,4 +1,6 @@ -"""Write examples/manifest.json: the nested tree of examples for the page. +"""Write examples//manifest.json, for each version in +examples/versions.json: the nested tree of that version's examples for the +page. Each directory node lists its subdirectories, its .py examples and its other files, which are copied next to the example before it runs (priors, data files, @@ -105,17 +107,17 @@ def describe(path): } -def walk(directory, adaptations): +def walk(root, directory, adaptations): node = {"name": directory.name, "dirs": [], "examples": [], "files": []} for path in sorted(directory.iterdir()): if path.name.startswith(".") or path.name in SKIP or path.name == "__pycache__": continue if path.is_dir(): - child = walk(path, adaptations) + child = walk(root, path, adaptations) if child["examples"] or child["dirs"]: node["dirs"].append(child) elif path.suffix == ".py": - key = path.relative_to(ROOT).as_posix() + key = path.relative_to(root).as_posix() node["examples"].append({ "name": path.name, "path": key, @@ -128,11 +130,10 @@ def walk(directory, adaptations): if __name__ == "__main__": - out = ROOT / "manifest.json" - adaptations_file = ROOT / "adaptations.json" - adaptations = json.loads(adaptations_file.read_text()) if adaptations_file.exists() else {} - tree = walk(ROOT, adaptations) - commit = ROOT / ".commit" - tree["commit"] = commit.read_text().strip() if commit.exists() else None - out.write_text(json.dumps(tree, indent=1) + "\n") - print(f"wrote {out}") + for version in json.loads((ROOT / "versions.json").read_text())["versions"]: + root = ROOT / version["name"] + adaptations_file = root / "adaptations.json" + adaptations = json.loads(adaptations_file.read_text()) if adaptations_file.exists() else {} + out = root / "manifest.json" + out.write_text(json.dumps(walk(root, root, adaptations), indent=1) + "\n") + print(f"wrote {out}") diff --git a/scripts/test-all.mjs b/scripts/test-all.mjs index 67b443c..299d7b7 100644 --- a/scripts/test-all.mjs +++ b/scripts/test-all.mjs @@ -1,8 +1,11 @@ -// Run every supported example (bilby test mode) and compare with -// tests/known-failures.json. Each example runs in its own Node process -// (scripts/test-examples.mjs), several at a time. +// Run every supported example (bilby test mode) of each bilby version and +// compare with tests/known-failures.json. Each example runs in its own Node +// process (scripts/test-examples.mjs), several at a time. // -// node scripts/test-all.mjs [--jobs N] [--timeout SECONDS] +// node scripts/test-all.mjs [--bilby NAME] [--jobs N] [--timeout SECONDS] +// +// --bilby tests one version (a name in examples/versions.json); without it, +// every version is tested. // // Exits non-zero if an example fails that is not a known failure. A known // failure that passes is reported so it can be removed from the list. @@ -15,27 +18,22 @@ import { fileURLToPath } from "node:url"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const arg = (name, fallback) => { const i = process.argv.indexOf(name); - return i > 0 ? Number(process.argv[i + 1]) : fallback; + return i > 0 ? process.argv[i + 1] : fallback; }; -const JOBS = arg("--jobs", Math.max(1, Math.min(4, availableParallelism() - 1))); -const TIMEOUT = arg("--timeout", 300); +const JOBS = Number(arg("--jobs", Math.max(1, Math.min(4, availableParallelism() - 1)))); +const TIMEOUT = Number(arg("--timeout", 300)); +const VERSIONS = arg("--bilby", null)?.split(",") + ?? JSON.parse(readFileSync(join(ROOT, "examples/versions.json"), "utf8")).versions.map((v) => v.name); -const known = JSON.parse(readFileSync(join(ROOT, "tests/known-failures.json"), "utf8")); -delete known._comment; -const manifest = JSON.parse(readFileSync(join(ROOT, "examples/manifest.json"), "utf8")); -const examples = []; -const skipped = []; -(function walk(node) { - for (const example of node.examples) { - (example.support?.status === "unsupported" ? skipped : examples).push(example.path); - } - node.dirs.forEach(walk); -})(manifest); +// {path: reason}: the failures known for every version, and for this one +const knownFailures = JSON.parse(readFileSync(join(ROOT, "tests/known-failures.json"), "utf8")); +const knownFor = (version) => ({ ...knownFailures.all, ...knownFailures[version] }); -function runOne(path) { +function runOne(version, path) { return new Promise((done) => { const start = performance.now(); - const child = spawn(process.execPath, [join(ROOT, "scripts/test-examples.mjs"), path], { cwd: ROOT }); + const args = [join(ROOT, "scripts/test-examples.mjs"), "--bilby", version, path]; + const child = spawn(process.execPath, args, { cwd: ROOT }); let output = ""; child.stdout.on("data", (d) => (output += d)); child.stderr.on("data", (d) => (output += d)); @@ -49,22 +47,43 @@ function runOne(path) { }); } -const results = []; -const queue = [...examples]; -await Promise.all(Array.from({ length: JOBS }, async () => { - while (queue.length) { - const result = await runOne(queue.shift()); - results.push(result); - const status = result.ok ? "ok" : result.timedOut ? `TIMEOUT >${TIMEOUT}s` : "FAIL"; - console.log(`${status.padEnd(14)} ${result.seconds.toFixed(1).padStart(6)} s ${result.path}${result.ok ? "" : " " + result.error.slice(0, 160)}`); +async function testVersion(version) { + const known = knownFor(version); + const manifest = JSON.parse(readFileSync(join(ROOT, "examples", version, "manifest.json"), "utf8")); + const examples = []; + const skipped = []; + (function walk(node) { + for (const example of node.examples) { + (example.support?.status === "unsupported" ? skipped : examples).push(example.path); + } + node.dirs.forEach(walk); + })(manifest); + for (const path of Object.keys(known)) { + if (!examples.includes(path)) console.log(`note: known failure ${path} is not a supported example of bilby ${version}`); } -})); -const unexpected = results.filter((r) => !r.ok && !(r.path in known)); -const fixed = results.filter((r) => r.ok && r.path in known); -console.log(`\n${results.filter((r) => r.ok).length}/${results.length} passed, ${skipped.length} unsupported skipped, ${JOBS} at a time`); -for (const r of fixed) console.log(`now passes (remove from tests/known-failures.json): ${r.path}`); -for (const r of unexpected) { - console.log(`\n----- unexpected failure: ${r.path}\n${r.output.split("\n").slice(-40).join("\n")}`); + console.log(`\n===== bilby ${version}`); + const results = []; + const queue = [...examples]; + await Promise.all(Array.from({ length: JOBS }, async () => { + while (queue.length) { + const result = await runOne(version, queue.shift()); + results.push(result); + const status = result.ok ? "ok" : result.timedOut ? `TIMEOUT >${TIMEOUT}s` : "FAIL"; + console.log(`${status.padEnd(14)} ${result.seconds.toFixed(1).padStart(6)} s ${result.path}${result.ok ? "" : " " + result.error.slice(0, 160)}`); + } + })); + + const unexpected = results.filter((r) => !r.ok && !(r.path in known)); + const fixed = results.filter((r) => r.ok && r.path in known); + console.log(`\nbilby ${version}: ${results.filter((r) => r.ok).length}/${results.length} passed, ${skipped.length} unsupported skipped, ${JOBS} at a time`); + for (const r of fixed) console.log(`now passes with bilby ${version} (remove from tests/known-failures.json): ${r.path}`); + for (const r of unexpected) { + console.log(`\n----- unexpected failure with bilby ${version}: ${r.path}\n${r.output.split("\n").slice(-40).join("\n")}`); + } + return unexpected.length; } -process.exit(unexpected.length ? 1 : 0); + +let unexpected = 0; +for (const version of VERSIONS) unexpected += await testVersion(version); +process.exit(unexpected ? 1 : 0); diff --git a/scripts/test-examples.mjs b/scripts/test-examples.mjs index 801e736..bd40c41 100644 --- a/scripts/test-examples.mjs +++ b/scripts/test-examples.mjs @@ -3,6 +3,8 @@ // node scripts/test-examples.mjs # install + import check only // node scripts/test-examples.mjs core_examples/linear_regression.py ... // node scripts/test-examples.mjs --full ... # without bilby test mode +// node scripts/test-examples.mjs --bilby 2.6.0 ... # another bilby version +// # (default: the site's default) // // Uses the same py/bootstrap.py, wheels and package lock as the browser // worker; LALSuite and extras are installed when an example needs them. @@ -15,8 +17,14 @@ import { loadPyodide } from "pyodide"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // bilby test mode (short sampling runs) is on unless --full is given -const full = process.argv.includes("--full"); -const examples = process.argv.slice(2).filter((a) => a !== "--full"); +const args = process.argv.slice(2); +const full = args.includes("--full"); +const versionAt = args.indexOf("--bilby"); +const versions = JSON.parse(readFileSync(join(ROOT, "examples/versions.json"), "utf8")); +const BILBY = versionAt >= 0 ? args[versionAt + 1] : versions.default; +if (!versions.versions.some((v) => v.name === BILBY)) throw new Error(`no bilby ${BILBY} in examples/versions.json`); +const EXAMPLES = join(ROOT, "examples", BILBY); +const examples = args.filter((a, i) => a !== "--full" && (versionAt < 0 || (i !== versionAt && i !== versionAt + 1))); const t0 = performance.now(); const elapsed = () => ((performance.now() - t0) / 1000).toFixed(1) + " s"; @@ -42,6 +50,7 @@ try { } pyodide.FS.mkdirTree("/tmp/wheels/pypi"); const urls = {}; +index.bilby = index.bilby[BILBY]; for (const [key, wheel] of Object.entries(index)) { if (!wheel) continue; pyodide.FS.writeFile(`/tmp/wheels/${wheel}`, readFileSync(join(WHEELS, wheel))); @@ -51,7 +60,7 @@ for (const wheel of readdirSync(join(WHEELS, "pypi"))) { pyodide.FS.writeFile(`/tmp/wheels/pypi/${wheel}`, readFileSync(join(WHEELS, "pypi", wheel))); } const pyUrls = pyodide.toPy(urls); -py("set_lock")(lock, "emfs:/tmp/wheels/pypi/", pyUrls); +py("set_lock")(lock, "emfs:/tmp/wheels/pypi/", pyUrls, BILBY); pyUrls.destroy(); console.log(`bilby test mode ${full ? "off" : "on"}`); @@ -65,7 +74,7 @@ let failed = 0; for (const path of examples) { console.log(`\n=== ${path}`); const dir = dirname(path); - const code = readFileSync(join(ROOT, "examples", path), "utf8"); + const code = readFileSync(join(EXAMPLES, path), "utf8"); if (!lalsuite && needsLalsuite(path, code)) { console.log("LAL", await py("install_lalsuite")(), "installed after", elapsed()); lalsuite = true; @@ -75,8 +84,8 @@ for (const path of examples) { py("set_test_mode")(!full); const globals = pyodide.toPy(py("prepare")(path).toJs({ dict_converter: Object.fromEntries })); // copy the example's sibling data files (priors, text data, images) - for (const name of readdirSync(join(ROOT, "examples", dir))) { - const src = join(ROOT, "examples", dir, name); + for (const name of readdirSync(join(EXAMPLES, dir))) { + const src = join(EXAMPLES, dir, name); if (statSync(src).isFile() && !name.endsWith(".py")) { pyodide.FS.writeFile(join("/home/pyodide/examples", dir, name), readFileSync(src)); } diff --git a/scripts/write_licenses.py b/scripts/write_licenses.py index 400ff57..a24d2a0 100644 --- a/scripts/write_licenses.py +++ b/scripts/write_licenses.py @@ -1,7 +1,7 @@ """Write licenses.html: what software this site distributes, under which licences, and where the exact source of each is. -Generated from the files that pin everything (bilby-commit.txt, +Generated from the files that pin everything (bilby-versions.json, lalsuite-wheel.json, packages.lock.json), so it always matches what is deployed. Run by scripts/dev.sh and the Pages workflow; the licence texts it links to are in licenses/. @@ -25,24 +25,25 @@ def row(*cells): def main(): - bilby_commit = (ROOT / "bilby-commit.txt").read_text().strip() + bilby = json.loads((ROOT / "bilby-versions.json").read_text())["versions"] lal = json.loads((ROOT / "lalsuite-wheel.json").read_text()) lock = json.loads((ROOT / "packages.lock.json").read_text()) - bilby_tree = f"https://github.com/bilby-dev/bilby/tree/{bilby_commit}" lal_tree = f"{lal['source_repo']}/-/tree/{lal['source_commit']}" - hosted = [ - row( - "bilby (wheel)", "MIT", link("examples/LICENSE.md", "licence"), - f"{link(bilby_tree, 'bilby-dev/bilby@' + bilby_commit[:8])}, built by " - f"{link(REPO + '/blob/main/scripts/fetch-bilby.sh', 'scripts/fetch-bilby.sh')}", - ), - row( - "bilby examples", "MIT", link("examples/LICENSE.md", "licence"), - f"{link(bilby_tree + '/examples', 'examples at the same commit')}, adapted for " - f"Pyodide by {link(REPO + '/blob/main/scripts/adapt_examples.py', 'scripts/adapt_examples.py')} " + hosted = [] + for version in bilby: + tree = f"https://github.com/bilby-dev/bilby/tree/{version['commit']}" + licence = link(f"examples/{version['name']}/LICENSE.md", "licence") + source = link(tree, "bilby-dev/bilby@" + (version.get("tag") or version["commit"][:8])) + hosted.append(row( + f"bilby {html.escape(version['name'])} (wheel and examples)", "MIT", licence, + f"{source}, wheel built by " + f"{link(REPO + '/blob/main/scripts/fetch-bilby.sh', 'scripts/fetch-bilby.sh')}; " + f"{link(tree + '/examples', 'examples')} adapted for Pyodide by " + f"{link(REPO + '/blob/main/scripts/adapt_examples.py', 'scripts/adapt_examples.py')} " "(each change is listed in the page above the example)", - ), + )) + hosted += [ row( f"igwn-segments {IGWN_SEGMENTS_VERSION} (modified)", "GPL-3.0-or-later", link("licenses/GPL-3.0.txt", "GPL-3.0"), @@ -75,7 +76,7 @@ def main(): for lib in lal["bundled"] ] pypi = {} - for group in lock["groups"].values(): + for group in [*lock["bilby"].values(), *lock["groups"].values()]: for wheel in group["pypi"]: pypi[wheel["file"]] = wheel pypi_rows = [ diff --git a/scripts/write_sizes.py b/scripts/write_sizes.py index 4352182..9578e48 100644 --- a/scripts/write_sizes.py +++ b/scripts/write_sizes.py @@ -62,7 +62,8 @@ def pyodide_sizes(version, names): def main(): lock = json.loads((ROOT / "packages.lock.json").read_text()) - names = {name for group in lock["groups"].values() for name in group["pyodide"]} + groups = [*lock["bilby"].values(), *lock["groups"].values()] + names = {name for group in groups for name in group["pyodide"]} out = {"site": { path.relative_to(ROOT).as_posix(): path.stat().st_size for path in sorted(WHEELS.glob("*.whl")) + sorted((WHEELS / "pypi").glob("*.whl")) diff --git a/scripts/write_wheel_index.py b/scripts/write_wheel_index.py index 04a0838..a97c177 100644 --- a/scripts/write_wheel_index.py +++ b/scripts/write_wheel_index.py @@ -1,4 +1,6 @@ -"""Write wheels/index.json: the newest wheel of each kind the page installs. +"""Write wheels/index.json: the wheel of each kind the page installs. For +bilby, the wheel of each version in examples/versions.json (written by +scripts/fetch-bilby.sh), by version name. Run by scripts/dev.sh and the Pages workflow, so the index always matches the wheels that are actually present. @@ -7,15 +9,16 @@ import json from pathlib import Path -WHEELS = Path(__file__).resolve().parent.parent / "wheels" +ROOT = Path(__file__).resolve().parent.parent +WHEELS = ROOT / "wheels" KINDS = { - "bilby": "bilby-*.whl", "lalsuite": "lalsuite-*.whl", "igwn_segments": "igwn_segments-*.whl", } if __name__ == "__main__": - index = {} + versions = json.loads((ROOT / "examples" / "versions.json").read_text())["versions"] + index = {"bilby": {v["name"]: v["wheel"] for v in versions if (WHEELS / v["wheel"]).exists()}} for key, pattern in KINDS.items(): found = sorted(p.name for p in WHEELS.glob(pattern)) index[key] = found[-1] if found else None diff --git a/tests/known-failures.json b/tests/known-failures.json index 4eae24a..fed2271 100644 --- a/tests/known-failures.json +++ b/tests/known-failures.json @@ -1,14 +1,27 @@ { - "_comment": "Examples expected to fail under scripts/test-all.mjs (bilby test mode). Unsupported examples (scripts/adapt_examples.py) are skipped, not listed here.", - "core_examples/linear_regression_grid.py": "test mode: plot_corner returns None, which the example uses", - "core_examples/linear_regression_with_Fisher.py": "test mode: plot_corner returns None, which the example uses", - "gw_examples/injection_examples/fake_sampler_example.py": "test mode: plot_corner returns None, which the example uses", - "gw_examples/injection_examples/non_tensor.py": "test mode: plot_corner returns None, which the example uses", - "gw_examples/injection_examples/relative_binning.py": "test mode: plot_corner returns None, which the example uses", - "gw_examples/injection_examples/plot_time_domain_data.py": "test mode: plot_time_domain_data writes no PNG, which the example opens", - "core_examples/multivariate_gaussian_prior.py": "bilby main: PriorDict.rescale cannot handle joint priors since #886", - "gw_examples/injection_examples/bns_polytrope_eos_example.py": "LALSuite: CreateSimNeutronStarFamily now needs min_fam", - "gw_examples/injection_examples/bns_spectral_pca_eos_example.py": "LALSuite: CreateSimNeutronStarFamily now needs min_fam", - "gw_examples/data_examples/GW150914.py": "Node: Pyodide has no network access under Node, so GWOSC data cannot load (works in the browser)", - "gw_examples/data_examples/GW190425.py": "Node: Pyodide has no network access under Node, so GWOSC data cannot load (works in the browser)" + "_comment": "Examples expected to fail under scripts/test-all.mjs (bilby test mode): under \"all\" for every bilby version, under a version's name for that version only. Unsupported examples (scripts/adapt_examples.py) are skipped, not listed here.", + "all": { + "core_examples/linear_regression_grid.py": "test mode: plot_corner returns None, which the example uses", + "core_examples/linear_regression_with_Fisher.py": "test mode: plot_corner returns None, which the example uses", + "gw_examples/injection_examples/fake_sampler_example.py": "test mode: plot_corner returns None, which the example uses", + "gw_examples/injection_examples/non_tensor.py": "test mode: plot_corner returns None, which the example uses", + "gw_examples/injection_examples/relative_binning.py": "test mode: plot_corner returns None, which the example uses", + "gw_examples/injection_examples/plot_time_domain_data.py": "test mode: plot_time_domain_data writes no PNG, which the example opens", + "gw_examples/injection_examples/bns_polytrope_eos_example.py": "LALSuite: CreateSimNeutronStarFamily now needs min_fam", + "gw_examples/injection_examples/bns_spectral_pca_eos_example.py": "LALSuite: CreateSimNeutronStarFamily now needs min_fam", + "gw_examples/data_examples/GW150914.py": "Node: Pyodide has no network access under Node, so GWOSC data cannot load (works in the browser)", + "gw_examples/data_examples/GW190425.py": "Node: Pyodide has no network access under Node, so GWOSC data cannot load (works in the browser)", + "core_examples/multivariate_gaussian_prior.py": "fails in every version: 2.6.0 under test mode (plot_corner returns None), later versions because PriorDict.rescale cannot handle joint priors since #886" + }, + "2.8.2": { + "gw_examples/injection_examples/calibration_marginalization_example.py": "releases: reweighting always opens a multiprocessing.Pool, which Pyodide does not have (main uses bilby's own pool)", + "gw_examples/injection_examples/marginalized_likelihood.py": "bilby: compute_per_detector_log_likelihood raises KeyError 'time_jitter' with time marginalization" + }, + "2.7.1": { + "gw_examples/injection_examples/calibration_marginalization_example.py": "releases: reweighting always opens a multiprocessing.Pool, which Pyodide does not have (main uses bilby's own pool)", + "gw_examples/injection_examples/marginalized_likelihood.py": "bilby: compute_per_detector_log_likelihood raises KeyError 'time_jitter' with time marginalization" + }, + "2.6.0": { + "gw_examples/injection_examples/calibration_marginalization_example.py": "releases: reweighting always opens a multiprocessing.Pool, which Pyodide does not have (main uses bilby's own pool)" + } } diff --git a/tests/smoke.spec.mjs b/tests/smoke.spec.mjs index 54dd755..e70e3cb 100644 --- a/tests/smoke.spec.mjs +++ b/tests/smoke.spec.mjs @@ -1,9 +1,14 @@ -// Load the page, run one core and one GW example in bilby test mode, and -// check the licences page. Run with: npx playwright test +// Load the page, run one core and one GW example in bilby test mode, switch +// bilby versions, and check the licences page. Run with: npx playwright test +// (BILBY_VERSION= runs the examples with that bilby version instead of +// the site's default). import { expect, test } from "@playwright/test"; +const BILBY = process.env.BILBY_VERSION; +const query = BILBY ? `?bilby=${encodeURIComponent(BILBY)}` : ""; + async function runExample(page, path) { - await page.goto(`./#${path}`); + await page.goto(`./${query}#${path}`); await expect(page.locator("#title")).toHaveText(path); await expect(page.locator("#status")).toHaveText(/^Ready/, { timeout: 180_000 }); await expect(page.locator("body")).toHaveClass(/test-mode/); // the default @@ -37,6 +42,22 @@ test("GW example installs LALSuite and runs", async ({ page }) => { await expect(page.locator("#output .info", { hasText: /^LAL .* installed$/ })).toBeVisible(); }); +test("the version picker restarts Python with another bilby", async ({ page }) => { + await page.goto("./#core_examples/linear_regression.py"); + await expect(page.locator("#status")).toHaveText(/^Ready/, { timeout: 180_000 }); + const versions = await page.evaluate(() => fetch("examples/versions.json").then((r) => r.json())); + // BILBY_VERSION if that is not the default, else any other version + const target = versions.versions.find((v) => v.name === BILBY && v.name !== versions.default) + ?? versions.versions.find((v) => v.name !== versions.default); + const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + await page.evaluate(() => editor.load("import bilby\nprint('running bilby', bilby.__version__)\n")); + await page.selectOption("#bilby-version", target.name); + await expect(page).toHaveURL(new RegExp(`\\?bilby=${escape(target.name)}#core_examples/linear_regression\\.py$`)); + await expect(page.locator("#status")).toHaveText(new RegExp(`^Ready: bilby ${escape(target.version)} ·`), { timeout: 180_000 }); + await page.click("#run"); + await expect(page.locator("#output")).toContainText(`running bilby ${target.version}`, { timeout: 60_000 }); +}); + test("licences page lists the hosted components", async ({ page }) => { await page.goto("./licenses.html"); await expect(page.locator("h1")).toHaveText("Licences and source code"); diff --git a/worker.js b/worker.js index 731bb6f..cb268bf 100644 --- a/worker.js +++ b/worker.js @@ -1,5 +1,9 @@ -// Runs Pyodide and bilby off the main thread. Messages: -// in: {type: "run", path, code, files, uploads, testMode, logLevel} +// Runs Pyodide and bilby off the main thread, for the bilby version named by +// "bilby" in this script's URL (worker.js?v=&bilby=, one of +// the names in examples/versions.json). Messages: +// in: {type: "run", path, code, files, filesFrom, uploads, testMode, logLevel} +// (files: the example's data files, from examples//; the +// example can be from another bilby version than this worker's) // (uploads: [{name, bytes}], files the user added, written next to the example) // in: {type: "read", path} (a file the run wrote; answered with "file") // out: {type: "status" | "progress" | "ready" | "info" | "stream" | "image" | "files" | "file" | "results" | "done" | "error", ...} @@ -24,8 +28,11 @@ function stream(kind) { const REPORT = "Please report this at https://github.com/ColmTalbot/bilby-browser/issues"; const siteUrl = (path) => new URL(path, self.location).href; +const params = new URLSearchParams(self.location.search); +const BILBY = params.get("bilby"); // "?v=" from app.js, so a new deploy never mixes with cached files -const versioned = (path) => path + self.location.search; +// (only that: files shared by all bilby versions are cached once) +const versioned = (path) => (params.has("v") ? `${path}?v=${params.get("v")}` : path); async function fetchText(path, what) { const response = await fetch(versioned(path)); @@ -95,6 +102,9 @@ async function withProgress(label, expected, fn) { // The site-built wheel in each group (see GROUPS in py/bootstrap.py). const LOCAL_WHEELS = { bilby: "bilby", lalsuite: "lalsuite", gwpy: "igwn_segments" }; +// The file name of a site-built wheel (bilby's for this worker's version). +const localWheel = (key) => (key === "bilby" ? index.bilby[BILBY] : index[key]); + // Bytes still to download to install these groups, or 0 if unknown. function groupBytes(names) { const loaded = pyodide ? pyodide.loadedPackages : {}; @@ -108,7 +118,7 @@ function groupBytes(names) { files.set("pyodide:" + pkg, size); } for (const wheel of group.pypi) files.set(wheel.file, sizes.site["wheels/pypi/" + wheel.file] ?? 0); - const local = index[LOCAL_WHEELS[name]]; + const local = localWheel(LOCAL_WHEELS[name]); if (local) files.set(local, sizes.site["wheels/" + local] ?? 0); } return [...files.values()].reduce((a, b) => a + b, 0); @@ -120,6 +130,10 @@ async function init() { const lockText = await fetchText("wheels/packages.lock.json", "package lock"); lock = JSON.parse(lockText); index = JSON.parse(await fetchText("wheels/index.json", "package list")); + if (!lock.bilby[BILBY] || !index.bilby[BILBY]) { + throw new Error(`This site has no bilby ${BILBY}. ${REPORT}`); + } + lock.groups.bilby = lock.bilby[BILBY]; try { sizes = JSON.parse(await fetchText("wheels/sizes.json", "file sizes")); } catch { @@ -139,12 +153,13 @@ async function init() { pyodide.runPython(await fetchText("py/bootstrap.py", "Python setup code")); py("add_module")("gwosc_range", await fetchText("py/gwosc_range.py", "Python setup code")); const urls = {}; - for (const [key, wheel] of Object.entries(index)) { + for (const key of Object.keys(index)) { + const wheel = localWheel(key); if (wheel) urls[key] = siteUrl("wheels/" + wheel); } const pyUrls = pyodide.toPy(urls); try { - py("set_lock")(lockText, siteUrl("wheels/pypi/"), pyUrls); + py("set_lock")(lockText, siteUrl("wheels/pypi/"), pyUrls, BILBY); } finally { pyUrls.destroy(); } @@ -165,7 +180,7 @@ function installLalsuite() { const needsLalsuite = (path, code) => path.startsWith("gw_examples/") || /\b(lal\w*|bilby\.gw)\b/.test(code); -async function run({ path, code, files, uploads = [], testMode, logLevel = "ERROR" }) { +async function run({ path, code, files, filesFrom = BILBY, uploads = [], testMode, logLevel = "ERROR" }) { const start = performance.now(); watched = null; if (needsLalsuite(path, code)) { @@ -183,7 +198,7 @@ async function run({ path, code, files, uploads = [], testMode, logLevel = "ERRO // copy the example's data files (priors, text data, images) next to it const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; for (const name of files) { - const response = await fetch(versioned(`examples/${dir}/${name}`)); + const response = await fetch(versioned(`examples/${filesFrom}/${dir}/${name}`)); pyodide.FS.writeFile(name, new Uint8Array(await response.arrayBuffer())); } // and the user's own files, after them so an upload can replace one; dated