From 6dcca2c7380431e5fc54f7d5692d905bfbd80415 Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 21:55:12 -0400 Subject: [PATCH 1/8] add deps to auto-test notebooks --- pyproject.toml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 81d6149..fc5dead 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,28 @@ where = ["src"] # Default `pytest` runs only the test suite; benchmarks/ is opt-in and is run # explicitly (`pytest benchmarks/`, requires the [benchmark] extra). testpaths = ["tests"] +# Notebook-execution tests are heavy and excluded from the standard suite; they +# run only in the release pipeline (`pytest -m notebooks`, see release.yml). +addopts = "-m 'not notebooks'" +markers = [ + "notebooks: end-to-end execution of the example notebooks (run with '-m notebooks')", +] filterwarnings = [ "ignore:Casting complex values to real discards the imaginary part:numpy.exceptions.ComplexWarning:jax._src.lax.lax", ] [project.optional-dependencies] -dev = ["pre-commit==3.7.0", "pytest==9.0.3", "pytest-benchmark==5.1.0", "black==24.3.0"] +dev = [ + "pre-commit==3.7.0", + "pytest==9.0.3", + "pytest-benchmark==5.1.0", + "black==24.3.0", + # Notebook execution tests (tests/test_notebooks.py). + "nbconvert==7.16.6", + "nbformat==5.10.4", + "ipykernel==6.30.1", + "matplotlib==3.10.7", +] docs = [ "matplotlib==3.10.7", From 130abf7eb7bc9708dcad07bae663b3713c887388 Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 21:59:32 -0400 Subject: [PATCH 2/8] notebook testing CI --- .github/workflows/notebooks.yml | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/notebooks.yml diff --git a/.github/workflows/notebooks.yml b/.github/workflows/notebooks.yml new file mode 100644 index 0000000..31c9191 --- /dev/null +++ b/.github/workflows/notebooks.yml @@ -0,0 +1,47 @@ +name: Notebooks + +# Execute the example notebooks end-to-end. These tests are heavy and excluded +# from the standard suite (marked `notebooks`, deselected by default via addopts +# in pyproject.toml), so they live in their own workflow. +# +# - Runs on PRs/pushes that touch the notebooks or their test harness. +# - Reusable: called from release.yml so a broken notebook blocks publishing. +# - Manually triggerable (workflow_dispatch). +on: + workflow_call: + workflow_dispatch: + push: + branches: [ main ] + paths: + - 'docs/examples/**.ipynb' + - 'tests/test_notebooks.py' + - 'pyproject.toml' + - '.github/workflows/notebooks.yml' + pull_request: + branches: [ main ] + paths: + - 'docs/examples/**.ipynb' + - 'tests/test_notebooks.py' + - 'pyproject.toml' + - '.github/workflows/notebooks.yml' + +jobs: + test-notebooks: + name: Execute example notebooks + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install package + notebook test deps + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Execute example notebooks + run: pytest -m notebooks -v From 00c7668854dd92423af127fad87389265112ffcb Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 21:59:40 -0400 Subject: [PATCH 3/8] trigger notebook tests on release --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e2bb62..8aa08c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,10 +79,17 @@ jobs: - name: Run test suite against the installed package run: pytest tests/ -v + # 2b. Execute the example notebooks end-to-end. These are heavy and excluded + # from the standard suite (marked `notebooks`, deselected by default via + # addopts), so they run via the dedicated reusable workflow. + test-notebooks: + name: Execute example notebooks + uses: ./.github/workflows/notebooks.yml + # 3. Publish to TestPyPI (trusted publishing — no API token). publish-testpypi: name: Publish to TestPyPI - needs: test-install + needs: [test-install, test-notebooks] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: testpypi From 3e162671f4b8ce56eaf59f9406c66533f26077fd Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 22:00:20 -0400 Subject: [PATCH 4/8] notebook tests --- tests/test_notebooks.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/test_notebooks.py diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py new file mode 100644 index 0000000..edf2975 --- /dev/null +++ b/tests/test_notebooks.py @@ -0,0 +1,34 @@ +"""Execute the example notebooks end-to-end to guard against example rot. + +Every notebook under ``docs/examples/`` is re-run from a clean state with +nbconvert's :class:`~nbconvert.preprocessors.ExecutePreprocessor`. A test +fails if any cell raises, surfacing the offending cell's traceback via +nbclient's ``CellExecutionError``. +""" + +from pathlib import Path + +import nbformat +import pytest +from nbconvert.preprocessors import ExecutePreprocessor + +# docs/examples relative to the repo root (mirrors the path idiom in conftest.py). +EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "docs" / "examples" +EXAMPLE_NOTEBOOKS = sorted(EXAMPLES_DIR.glob("*.ipynb")) + +# Generous per-notebook ceiling so a hung cell fails rather than blocks forever. +NOTEBOOK_TIMEOUT = 900 + +# Heavy: excluded from the standard suite, run only in the release pipeline. +pytestmark = pytest.mark.notebooks + + +@pytest.mark.parametrize( + "notebook_path", EXAMPLE_NOTEBOOKS, ids=lambda p: p.name +) +def test_example_notebook_runs(notebook_path): + """Execute ``notebook_path`` and assert that no cell raises.""" + nb = nbformat.read(notebook_path, as_version=4) + ep = ExecutePreprocessor(timeout=NOTEBOOK_TIMEOUT, kernel_name="python3") + # Run with the notebook's own directory as cwd so relative paths resolve. + ep.preprocess(nb, {"metadata": {"path": str(notebook_path.parent)}}) From 5c7d79e0504fb15509eed0461938b37dec6528e2 Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 22:03:39 -0400 Subject: [PATCH 5/8] check dir exists --- docs/examples/pulse_smoothing.ipynb | 62 +---------------------------- 1 file changed, 2 insertions(+), 60 deletions(-) diff --git a/docs/examples/pulse_smoothing.ipynb b/docs/examples/pulse_smoothing.ipynb index 440ee21..33d5be4 100644 --- a/docs/examples/pulse_smoothing.ipynb +++ b/docs/examples/pulse_smoothing.ipynb @@ -506,65 +506,7 @@ "id": "0b56bdb7-3415-4157-a2d8-abb4b96e2c9b", "metadata": {}, "outputs": [], - "source": [ - "alpha = 0.05\n", - "y_limits = [-8.5,3]\n", - "init_step = steps_2[0]\n", - "smoothed_step = -1\n", - "init_scaling = opt.history.parameters[init_step].shape[0]\n", - "smoothed_scaling = opt.history.parameters[smoothed_step].shape[0]\n", - "gates = [float(i)/init_scaling for i in range(-1,opt.history.parameters[init_step].shape[0]+1)]\n", - "smoothed_gates = [float(i)/smoothed_scaling for i in range(-1,opt.history.parameters[smoothed_step].shape[0]+1)]\n", - "\n", - "fig, axs = plt.subplots(1, 2, figsize=(10, 2.5), sharex=True)\n", - "a=0\n", - "for i, strength in enumerate(opt.history.parameters[init_step].T):\n", - " strength = [s*init_scaling for s in strength]\n", - " if opt.params.basis.labels[i] in opt.params.projected_basis.labels:\n", - " axs[a].step(gates, \n", - " np.append(np.array([0]), np.append(strength,np.array([0]))), \n", - " where='post', \n", - " label=opt.params.basis.plot_labels[i],\n", - " color=\"black\")\n", - " axs[a].fill_between(gates, \n", - " np.append(np.array([0]), np.append(strength,np.array([0]))), \n", - " step='post', \n", - " color=\"black\", \n", - " alpha=alpha)\n", - " a+=1\n", - "\n", - "a=0\n", - "for i, strength in enumerate(opt.history.parameters[smoothed_step].T):\n", - " strength = [s*smoothed_scaling for s in strength]\n", - " if opt.params.basis.labels[i] in opt.params.projected_basis.labels:\n", - " axs[a].step(smoothed_gates, \n", - " np.append(np.array([0]), np.append(strength,np.array([0]))), \n", - " where='post', \n", - " label=opt.params.basis.plot_labels[i],\n", - " color=\"green\")\n", - " axs[a].fill_between(smoothed_gates, \n", - " np.append(np.array([0]), np.append(strength,np.array([0]))), \n", - " step='post', \n", - " color=\"green\", \n", - " alpha=alpha)\n", - " a+=1\n", - "\n", - "for ax in axs:\n", - " # ax.legend(loc='upper right')\n", - " ax.grid(False)\n", - " ax.set_xlim(-0.01,gates[-1]+0.008)\n", - " # ax.set_ylim(y_limits[0],y_limits[1])\n", - "\n", - "axs[0].set_xlabel(\"Unit time, $t$\")\n", - "axs[1].set_xlabel(\"Unit time, $t$\")\n", - "\n", - "axs[0].set_ylabel(f\"$h_{{1}}(t)$\")\n", - "axs[1].set_ylabel(f\"$h_{{2}}(t)$\")\n", - "\n", - "plt.tight_layout()\n", - "os.makedirs(os.path.dirname(savefig), exist_ok=True)\n", - "plt.show()" - ] + "source": "alpha = 0.05\ny_limits = [-8.5,3]\ninit_step = steps_2[0]\nsmoothed_step = -1\ninit_scaling = opt.history.parameters[init_step].shape[0]\nsmoothed_scaling = opt.history.parameters[smoothed_step].shape[0]\ngates = [float(i)/init_scaling for i in range(-1,opt.history.parameters[init_step].shape[0]+1)]\nsmoothed_gates = [float(i)/smoothed_scaling for i in range(-1,opt.history.parameters[smoothed_step].shape[0]+1)]\n\nfig, axs = plt.subplots(1, 2, figsize=(10, 2.5), sharex=True)\na=0\nfor i, strength in enumerate(opt.history.parameters[init_step].T):\n strength = [s*init_scaling for s in strength]\n if opt.params.basis.labels[i] in opt.params.projected_basis.labels:\n axs[a].step(gates, \n np.append(np.array([0]), np.append(strength,np.array([0]))), \n where='post', \n label=opt.params.basis.plot_labels[i],\n color=\"black\")\n axs[a].fill_between(gates, \n np.append(np.array([0]), np.append(strength,np.array([0]))), \n step='post', \n color=\"black\", \n alpha=alpha)\n a+=1\n\na=0\nfor i, strength in enumerate(opt.history.parameters[smoothed_step].T):\n strength = [s*smoothed_scaling for s in strength]\n if opt.params.basis.labels[i] in opt.params.projected_basis.labels:\n axs[a].step(smoothed_gates, \n np.append(np.array([0]), np.append(strength,np.array([0]))), \n where='post', \n label=opt.params.basis.plot_labels[i],\n color=\"green\")\n axs[a].fill_between(smoothed_gates, \n np.append(np.array([0]), np.append(strength,np.array([0]))), \n step='post', \n color=\"green\", \n alpha=alpha)\n a+=1\n\nfor ax in axs:\n # ax.legend(loc='upper right')\n ax.grid(False)\n ax.set_xlim(-0.01,gates[-1]+0.008)\n # ax.set_ylim(y_limits[0],y_limits[1])\n\naxs[0].set_xlabel(\"Unit time, $t$\")\naxs[1].set_xlabel(\"Unit time, $t$\")\n\naxs[0].set_ylabel(f\"$h_{{1}}(t)$\")\naxs[1].set_ylabel(f\"$h_{{2}}(t)$\")\n\nplt.tight_layout()\nplt.show()" } ], "metadata": { @@ -588,4 +530,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file From ac7f18ed3c19d70a2beb47b9296845178b35b701 Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 22:09:25 -0400 Subject: [PATCH 6/8] black --- tests/test_notebooks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index edf2975..d750b4b 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -23,9 +23,7 @@ pytestmark = pytest.mark.notebooks -@pytest.mark.parametrize( - "notebook_path", EXAMPLE_NOTEBOOKS, ids=lambda p: p.name -) +@pytest.mark.parametrize("notebook_path", EXAMPLE_NOTEBOOKS, ids=lambda p: p.name) def test_example_notebook_runs(notebook_path): """Execute ``notebook_path`` and assert that no cell raises.""" nb = nbformat.read(notebook_path, as_version=4) From c30f2e2a4f91489f7f6ebcdee19eb219a1956df6 Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 22:15:53 -0400 Subject: [PATCH 7/8] guard imports --- tests/test_notebooks.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index d750b4b..7c2bf15 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -8,9 +8,16 @@ from pathlib import Path -import nbformat import pytest -from nbconvert.preprocessors import ExecutePreprocessor + +# Notebook deps live in the [dev] extra and are absent from the standard test +# job (which installs only `pip install -e .`). The `notebooks` marker keeps +# these tests deselected there, but collection still imports this module, so +# skip cleanly rather than erroring when the deps are missing. +nbformat = pytest.importorskip("nbformat") +ExecutePreprocessor = pytest.importorskip( + "nbconvert.preprocessors" +).ExecutePreprocessor # docs/examples relative to the repo root (mirrors the path idiom in conftest.py). EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "docs" / "examples" From 7b478eed5774bd3eb9434b57e20e0a50c1f3bf30 Mon Sep 17 00:00:00 2001 From: therooler Date: Fri, 26 Jun 2026 22:17:12 -0400 Subject: [PATCH 8/8] black --- tests/test_notebooks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_notebooks.py b/tests/test_notebooks.py index 7c2bf15..4b607dc 100644 --- a/tests/test_notebooks.py +++ b/tests/test_notebooks.py @@ -15,9 +15,7 @@ # these tests deselected there, but collection still imports this module, so # skip cleanly rather than erroring when the deps are missing. nbformat = pytest.importorskip("nbformat") -ExecutePreprocessor = pytest.importorskip( - "nbconvert.preprocessors" -).ExecutePreprocessor +ExecutePreprocessor = pytest.importorskip("nbconvert.preprocessors").ExecutePreprocessor # docs/examples relative to the repo root (mirrors the path idiom in conftest.py). EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "docs" / "examples"