From 55ed358a5b68e0825bbaf0764a1a677c2222d301 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Tue, 8 Sep 2026 19:57:56 +0000 Subject: [PATCH 01/10] Test notebook courses Code cells should run without exceptions, except those tagged `exercise`, which should fail with `ExerciseError` (not having been completed). Some cells are very slow - we tag those `skip-test`. --- .github/workflows/ci.yml | 83 +++++++ .../iterative_phase_estimation.ipynb | 6 +- .../courses/chemistry-qpe/_course_lib.py | 18 +- .../utils/chemistry-qpe/rst_to_notebook.py | 9 +- .../vscode/src/learning/notebookExercises.ts | 18 +- source/vscode/test/course-notebooks/README.md | 44 ++++ .../vscode/test/course-notebooks/conftest.py | 63 +++++ .../test/course-notebooks/notebook_runner.py | 226 ++++++++++++++++++ .../test/course-notebooks/requirements.txt | 3 + .../course-notebooks/test_course_notebooks.py | 11 + .../course-notebooks/test_notebook_runner.py | 70 ++++++ 11 files changed, 528 insertions(+), 23 deletions(-) create mode 100644 source/vscode/test/course-notebooks/README.md create mode 100644 source/vscode/test/course-notebooks/conftest.py create mode 100644 source/vscode/test/course-notebooks/notebook_runner.py create mode 100644 source/vscode/test/course-notebooks/requirements.txt create mode 100644 source/vscode/test/course-notebooks/test_course_notebooks.py create mode 100644 source/vscode/test/course-notebooks/test_notebook_runner.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 142fc00f601..3d1536285cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,6 +220,87 @@ jobs: # which requires a display to run run: xvfb-run -a python ./build.py --no-check --no-test --wasm --npm --vscode --widgets --qdk --integration-tests + course-notebook-changes: + name: Detect course notebook changes + runs-on: ubuntu-latest + outputs: + run-tests: ${{ steps.changes.outputs.run-tests }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: github.event_name != 'workflow_dispatch' + with: + fetch-depth: 0 + - name: Determine whether to run course notebook tests + id: changes + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + run_tests=true + elif git diff --quiet "$BASE_SHA...$HEAD_SHA" -- \ + source/vscode/resources/qdk-learning/courses/ \ + source/vscode/test/course-notebooks/; then + run_tests=false + else + run_tests=true + fi + echo "run-tests=$run_tests" >> "$GITHUB_OUTPUT" + + course-notebook-tests: + name: Course notebooks (${{ matrix.course }}, ${{ matrix.arch }}) + needs: course-notebook-changes + if: needs.course-notebook-changes.outputs.run-tests == 'true' + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - course: chemistry-qpe + runner: ubuntu-24.04 + arch: x64 + machine: x86_64 + pip-cache: ~/.cache/pip + - course: chemistry-qpe + runner: ubuntu-24.04-arm + arch: arm64 + machine: aarch64 + pip-cache: ~/.cache/pip + - course: chemistry-qpe + runner: windows-2025 + arch: x64 + machine: amd64 + pip-cache: ~/AppData/Local/pip/Cache + - course: chemistry-qpe + runner: windows-11-arm + arch: arm64 + machine: arm64 + pip-cache: ~/AppData/Local/pip/Cache + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + architecture: ${{ matrix.arch }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ matrix.pip-cache }} + key: ${{ runner.os }}-${{ matrix.arch }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/requirements.txt', format('source/vscode/resources/qdk-learning/courses/{0}/requirements.txt', matrix.course)) }} + restore-keys: | + ${{ runner.os }}-${{ matrix.arch }}-pip- + - name: Verify runner architecture + run: python -c "import platform; actual = platform.machine().lower(); expected = '${{ matrix.machine }}'; print(f'{actual=} {expected=}'); assert actual == expected" + - name: Install notebook dependencies + run: | + python -m pip install -r source/vscode/test/course-notebooks/requirements.txt + python -m pip install -r source/vscode/resources/qdk-learning/courses/${{ matrix.course }}/requirements.txt + - name: Test course notebooks + run: python -m pytest source/vscode/test/course-notebooks -v -s --course ${{ matrix.course }} + status-check: name: Status Check needs: @@ -233,6 +314,8 @@ jobs: unit-tests, format-qsc, integration-tests, + course-notebook-changes, + course-notebook-tests, ] runs-on: ubuntu-latest if: failure() diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb index 380339c7d64..9d5c6e49fdc 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/06-iterative-phase-estimation/iterative_phase_estimation.ipynb @@ -322,7 +322,11 @@ "cell_type": "code", "id": "c-0a2a3fcf99d8", "execution_count": null, - "metadata": {}, + "metadata": { + "tags": [ + "skip-test" + ] + }, "outputs": [], "source": "iqpe_result = run_iqpe_workflow()\nprint_iqpe_results(iqpe_result)" }, diff --git a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_course_lib.py b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_course_lib.py index 33e7ae86d9b..36280b65d8c 100644 --- a/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_course_lib.py +++ b/source/vscode/resources/qdk-learning/courses/chemistry-qpe/_course_lib.py @@ -16,7 +16,6 @@ from typing import Callable -from IPython.core.getipython import get_ipython from IPython.display import HTML, display # A learner exercise is a no-argument function whose result is checked. @@ -32,21 +31,8 @@ class ExerciseError(AssertionError): """Raised when an exercise is not yet correct.""" - -def _hide_traceback() -> None: - """Show only the failure banner for a wrong answer. - - The cell still ends in an error, which marks the exercise as incomplete, - but real errors raised by learner code keep their traceback. - """ - shell = get_ipython() - if shell is None: - return - - shell.set_custom_exc((ExerciseError,), lambda *args, **kwargs: None) - - -_hide_traceback() + def _render_traceback_(self) -> list[str]: + return [] def _register(name: str, checker: Checker) -> str: diff --git a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/rst_to_notebook.py b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/rst_to_notebook.py index e910e6de41b..9df3c9e5292 100644 --- a/source/vscode/resources/qdk-learning/utils/chemistry-qpe/rst_to_notebook.py +++ b/source/vscode/resources/qdk-learning/utils/chemistry-qpe/rst_to_notebook.py @@ -797,7 +797,7 @@ "section": "The complete workflow", "cells": [ ( - "code", + "skip-test", "iqpe_result = run_iqpe_workflow()\n" "print_iqpe_results(iqpe_result)\n", ), @@ -1773,7 +1773,12 @@ def splice(section, block): cells[end:end] = block owner[end:end] = [section] * len(block) - kinds = {"md": md, "code": code, "region": lambda v: code(notebook_region(v))} + kinds = { + "md": md, + "code": code, + "skip-test": lambda value: code(value, tags=["skip-test"]), + "region": lambda value: code(notebook_region(value)), + } for spec in recipe.get("inserts", []): splice(spec["section"], [kinds[k](v) for k, v in spec["cells"]]) diff --git a/source/vscode/src/learning/notebookExercises.ts b/source/vscode/src/learning/notebookExercises.ts index fd4dde50f75..c6889245fc2 100644 --- a/source/vscode/src/learning/notebookExercises.ts +++ b/source/vscode/src/learning/notebookExercises.ts @@ -42,6 +42,9 @@ const EXERCISE_TAG = "exercise"; /** Tags marking author-only cells, removed from the learner's working copy. */ const AUTHORING_TAGS = ["hint", "solution", "explanation"] as const; +/** Test-only tag removed from cells in the learner's working copy. */ +const SKIP_TEST_TAG = "skip-test"; + type AuthoringTag = (typeof AUTHORING_TAGS)[number]; /** The subset of an nbformat cell this module reads. */ @@ -190,10 +193,10 @@ export function parseNotebookActivities( * Remove the author-only cells from a notebook's JSON text, returning the * notebook the learner works in. * - * Everything else — including cell ids and the `exercise` tag — is preserved - * verbatim, so metadata parsed from the authored notebook still resolves - * against the working copy. Returns `undefined` if the text isn't a notebook, - * leaving the caller to decide on a fallback. + * Retained cells preserve their content, ids, and other tags, including the + * `exercise` tag. The test-only `skip-test` tag is removed so it doesn't leak + * into the learner's working copy. Returns `undefined` if the text isn't a + * notebook, leaving the caller to decide on a fallback. */ export function stripAuthoringCells( text: string, @@ -209,6 +212,13 @@ export function stripAuthoringCells( return !AUTHORING_TAGS.some((t) => tags.includes(t)); }); + for (const cell of notebook.cells) { + const metadata = cell.metadata; + if (metadata && Array.isArray(metadata.tags)) { + metadata.tags = metadata.tags.filter((tag) => tag !== SKIP_TEST_TAG); + } + } + // Match the ipynb serializer's formatting so the file stays diff-stable // once VS Code starts saving it: one space of indent, trailing newline. return `${JSON.stringify(notebook, undefined, 1)}\n`; diff --git a/source/vscode/test/course-notebooks/README.md b/source/vscode/test/course-notebooks/README.md new file mode 100644 index 00000000000..1d95190d30d --- /dev/null +++ b/source/vscode/test/course-notebooks/README.md @@ -0,0 +1,44 @@ +# Course notebook tests + +These tests execute the source notebooks for one QDK learning course in fresh +Python kernels. Each notebook runs from an isolated copy of its full course +directory so relative imports work and generated files do not modify the source +tree. + +## Local setup + +Create a Python 3.11 environment and install the test harness plus the selected +course's dependencies: + +```shell +python3.11 -m venv .venv-course-notebooks +source .venv-course-notebooks/bin/activate +python -m pip install -r source/vscode/test/course-notebooks/requirements.txt +python -m pip install -r source/vscode/resources/qdk-learning/courses/chemistry-qpe/requirements.txt +``` + +Run the course suite from the repository root: + +```shell +python -m pytest source/vscode/test/course-notebooks -v -s --course chemistry-qpe +``` + +Run only the fast runner policy tests without installing course dependencies: + +```shell +python -m pytest source/vscode/test/course-notebooks/test_notebook_runner.py -v +``` + +## Cell metadata + +- An `exercise` code cell must raise `ExerciseError`. Any other exception, or + successful execution, fails the test. +- A `solution` code cell must execute without an error. +- A `skip-test` code cell is skipped only by this test suite. VS Code, Jupyter, + and ordinary notebook execution do not interpret this custom tag. + +Do not combine `exercise` and `skip-test`. When a skipped setup cell supplies +state to later cells, tag those dependent cells with `skip-test` as well. + +Each cell has a 120-second timeout. Cells taking longer than 30 seconds are +reported so expensive cells can be reviewed before adding a skip. diff --git a/source/vscode/test/course-notebooks/conftest.py b/source/vscode/test/course-notebooks/conftest.py new file mode 100644 index 00000000000..5b69fbb2611 --- /dev/null +++ b/source/vscode/test/course-notebooks/conftest.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from notebook_runner import discover_notebooks + +REPO_ROOT = Path(__file__).resolve().parents[4] +COURSES_ROOT = REPO_ROOT / "source/vscode/resources/qdk-learning/courses" + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption( + "--course", + help="Immediate child of the QDK learning courses directory to test", + ) + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "course_notebook" not in metafunc.fixturenames: + return + + course_dir = _selected_course_dir(metafunc.config) + notebooks = discover_notebooks(course_dir) + if not notebooks: + raise pytest.UsageError(f"no source notebooks found under {course_dir}") + metafunc.parametrize( + "course_notebook", + notebooks, + ids=[str(path.relative_to(course_dir)) for path in notebooks], + ) + + +@pytest.fixture +def isolated_course_notebook( + course_notebook: Path, + tmp_path: Path, +) -> tuple[Path, Path]: + course_dir = course_notebook.parents[1] + copied_course_dir = tmp_path / course_dir.name + shutil.copytree(course_dir, copied_course_dir) + copied_notebook = copied_course_dir / course_notebook.relative_to(course_dir) + return copied_notebook, course_notebook.relative_to(REPO_ROOT) + + +def _selected_course_dir(config: pytest.Config) -> Path: + course_name = config.getoption("course") + if not course_name: + raise pytest.UsageError( + "course notebook tests require --course, for example " + "--course chemistry-qpe" + ) + if Path(course_name).name != course_name or "\\" in course_name: + raise pytest.UsageError( + f"--course must name one immediate child of {COURSES_ROOT}" + ) + + course_dir = COURSES_ROOT / course_name + if not course_dir.is_dir(): + raise pytest.UsageError(f"course directory does not exist: {course_dir}") + return course_dir \ No newline at end of file diff --git a/source/vscode/test/course-notebooks/notebook_runner.py b/source/vscode/test/course-notebooks/notebook_runner.py new file mode 100644 index 00000000000..6eeee993e16 --- /dev/null +++ b/source/vscode/test/course-notebooks/notebook_runner.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from time import perf_counter +from typing import Any + +import nbformat +from nbclient import NotebookClient + +CELL_TIMEOUT_SECONDS = 120 +SLOW_CELL_SECONDS = 30 +SKIP_TEST_TAG = "skip-test" +EXERCISE_TAG = "exercise" + + +@dataclass(frozen=True) +class CellFailure: + cell_number: int + source_line: str + message: str + + +@dataclass(frozen=True) +class SlowCell: + cell_number: int + source_line: str + duration_seconds: float + + +@dataclass(frozen=True) +class NotebookRunReport: + notebook_path: Path + elapsed_seconds: float + executed_cells: int + skipped_cells: tuple[int, ...] + slow_cells: tuple[SlowCell, ...] + failures: tuple[CellFailure, ...] + + def format_failures(self) -> str: + return "\n".join( + f"{self.notebook_path}: cell {failure.cell_number} " + f"({failure.source_line}): {failure.message}" + for failure in self.failures + ) + + +def discover_notebooks(course_dir: Path) -> list[Path]: + return sorted( + path + for path in course_dir.rglob("*.ipynb") + if not path.name.endswith(".workbook.ipynb") + ) + + +def clear_notebook_outputs(notebook: Any) -> None: + for cell in notebook.cells: + if cell.cell_type != "code": + continue + cell.outputs = [] + cell.execution_count = None + cell.metadata.pop("execution", None) + + +def collect_cell_failures(notebook: Any) -> list[CellFailure]: + failures: list[CellFailure] = [] + for cell_number, cell in enumerate(notebook.cells, start=1): + if cell.cell_type != "code": + continue + + tags = set(cell.metadata.get("tags", [])) + source_line = _first_source_line(cell.source) + if EXERCISE_TAG in tags and SKIP_TEST_TAG in tags: + failures.append( + CellFailure( + cell_number, + source_line, + f"a cell cannot have both {EXERCISE_TAG!r} and {SKIP_TEST_TAG!r}", + ) + ) + continue + if SKIP_TEST_TAG in tags: + continue + + errors = [ + output + for output in cell.get("outputs", []) + if output.get("output_type") == "error" + ] + if EXERCISE_TAG in tags: + if not errors: + failures.append( + CellFailure( + cell_number, + source_line, + "exercise cell did not raise ExerciseError", + ) + ) + elif errors[0].get("ename") != "ExerciseError": + failures.append( + CellFailure( + cell_number, + source_line, + "exercise cell raised " + _format_error(errors[0]), + ) + ) + continue + + failures.extend( + CellFailure( + cell_number, + source_line, + "unexpected error: " + _format_error(error), + ) + for error in errors + ) + return failures + + +def run_notebook(notebook_path: Path, display_path: Path | None = None) -> NotebookRunReport: + notebook = nbformat.read(notebook_path, as_version=4) + clear_notebook_outputs(notebook) + + metadata_failures = [ + failure + for failure in collect_cell_failures(notebook) + if "cannot have both" in failure.message + ] + if metadata_failures: + return NotebookRunReport( + display_path or notebook_path, + 0.0, + 0, + (), + (), + tuple(metadata_failures), + ) + + started = perf_counter() + NotebookClient( + notebook, + timeout=CELL_TIMEOUT_SECONDS, + allow_errors=True, + kernel_name="python3", + resources={"metadata": {"path": str(notebook_path.parent)}}, + skip_cells_with_tag=SKIP_TEST_TAG, + store_widget_state=False, + ).execute() + elapsed_seconds = perf_counter() - started + + skipped_cells = tuple( + cell_number + for cell_number, cell in enumerate(notebook.cells, start=1) + if cell.cell_type == "code" and SKIP_TEST_TAG in cell.metadata.get("tags", []) + ) + slow_cells = tuple( + slow_cell + for cell_number, cell in enumerate(notebook.cells, start=1) + if (slow_cell := _slow_cell(cell_number, cell)) is not None + ) + executed_cells = sum( + 1 + for cell in notebook.cells + if cell.cell_type == "code" and SKIP_TEST_TAG not in cell.metadata.get("tags", []) + ) + report = NotebookRunReport( + display_path or notebook_path, + elapsed_seconds, + executed_cells, + skipped_cells, + slow_cells, + tuple(collect_cell_failures(notebook)), + ) + print_notebook_report(report) + return report + + +def print_notebook_report(report: NotebookRunReport) -> None: + print( + f"{report.notebook_path}: {report.elapsed_seconds:.1f}s, " + f"{report.executed_cells} executed, {len(report.skipped_cells)} skipped" + ) + for cell_number in report.skipped_cells: + print(f" skipped cell {cell_number} ({SKIP_TEST_TAG})") + for cell in report.slow_cells: + print( + f" slow cell {cell.cell_number}: {cell.duration_seconds:.1f}s " + f"({cell.source_line})" + ) + + +def _first_source_line(source: str) -> str: + for line in source.splitlines(): + if stripped := line.strip(): + return stripped + return "" + + +def _format_error(error: Any) -> str: + name = error.get("ename", "Error") + value = error.get("evalue", "") + return f"{name}: {value}" if value else name + + +def _slow_cell(cell_number: int, cell: Any) -> SlowCell | None: + if cell.cell_type != "code": + return None + execution = cell.metadata.get("execution", {}) + started = execution.get("iopub.status.busy") + finished = execution.get("iopub.status.idle") + if not started or not finished: + return None + duration = _parse_timestamp(finished) - _parse_timestamp(started) + duration_seconds = duration.total_seconds() + if duration_seconds <= SLOW_CELL_SECONDS: + return None + return SlowCell( + cell_number, + _first_source_line(cell.source), + duration_seconds, + ) + + +def _parse_timestamp(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) \ No newline at end of file diff --git a/source/vscode/test/course-notebooks/requirements.txt b/source/vscode/test/course-notebooks/requirements.txt new file mode 100644 index 00000000000..95aae8f178a --- /dev/null +++ b/source/vscode/test/course-notebooks/requirements.txt @@ -0,0 +1,3 @@ +nbclient +nbformat +pytest \ No newline at end of file diff --git a/source/vscode/test/course-notebooks/test_course_notebooks.py b/source/vscode/test/course-notebooks/test_course_notebooks.py new file mode 100644 index 00000000000..153fc4bf322 --- /dev/null +++ b/source/vscode/test/course-notebooks/test_course_notebooks.py @@ -0,0 +1,11 @@ +from pathlib import Path + +from notebook_runner import run_notebook + + +def test_course_notebook(isolated_course_notebook: tuple[Path, Path]) -> None: + notebook_path, display_path = isolated_course_notebook + + report = run_notebook(notebook_path, display_path) + + assert not report.failures, report.format_failures() \ No newline at end of file diff --git a/source/vscode/test/course-notebooks/test_notebook_runner.py b/source/vscode/test/course-notebooks/test_notebook_runner.py new file mode 100644 index 00000000000..cf52ebe3815 --- /dev/null +++ b/source/vscode/test/course-notebooks/test_notebook_runner.py @@ -0,0 +1,70 @@ +import nbformat + +from notebook_runner import collect_cell_failures + + +def _notebook(*cells): + return nbformat.v4.new_notebook(cells=list(cells)) + + +def _code_cell(*, tags=(), error=None): + cell = nbformat.v4.new_code_cell("answer = 42", metadata={"tags": list(tags)}) + if error is not None: + name, value = error + cell.outputs = [ + nbformat.v4.new_output( + "error", + ename=name, + evalue=value, + traceback=[], + ) + ] + return cell + + +def test_exercise_requires_exercise_error(): + notebook = _notebook(_code_cell(tags=["exercise"], error=("ExerciseError", "try again"))) + + assert collect_cell_failures(notebook) == [] + + +def test_exercise_that_succeeds_fails_policy(): + notebook = _notebook(_code_cell(tags=["exercise"])) + + failures = collect_cell_failures(notebook) + + assert len(failures) == 1 + assert failures[0].message == "exercise cell did not raise ExerciseError" + + +def test_exercise_with_wrong_error_fails_policy(): + notebook = _notebook(_code_cell(tags=["exercise"], error=("ValueError", "bad value"))) + + failures = collect_cell_failures(notebook) + + assert len(failures) == 1 + assert failures[0].message == "exercise cell raised ValueError: bad value" + + +def test_ordinary_cell_error_fails_policy(): + notebook = _notebook(_code_cell(error=("RuntimeError", "broken"))) + + failures = collect_cell_failures(notebook) + + assert len(failures) == 1 + assert failures[0].message == "unexpected error: RuntimeError: broken" + + +def test_skip_test_cell_is_not_evaluated(): + notebook = _notebook(_code_cell(tags=["skip-test"], error=("RuntimeError", "ignored"))) + + assert collect_cell_failures(notebook) == [] + + +def test_exercise_cannot_be_skipped(): + notebook = _notebook(_code_cell(tags=["exercise", "skip-test"])) + + failures = collect_cell_failures(notebook) + + assert len(failures) == 1 + assert failures[0].message == "a cell cannot have both 'exercise' and 'skip-test'" \ No newline at end of file From 5ab4e904130638104fb3b61f9ab469464430ce88 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 11:09:40 -0700 Subject: [PATCH 02/10] Make notebook tests more consistent with integration tests --- .ado/publish.yml | 22 ++++ .github/workflows/ci.yml | 22 ++-- build.py | 28 ++++- source/vscode/test/course-notebooks/README.md | 32 +++--- .../vscode/test/course-notebooks/conftest.py | 101 ++++++++++++------ .../test/course-notebooks/notebook_runner.py | 26 +++-- .../course-notebooks/test_course_notebooks.py | 8 +- ...requirements.txt => test_requirements.txt} | 2 +- 8 files changed, 165 insertions(+), 76 deletions(-) rename source/vscode/test/course-notebooks/{requirements.txt => test_requirements.txt} (72%) diff --git a/.ado/publish.yml b/.ado/publish.yml index ed4d4faa95e..7ecf3e6ffd9 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -348,6 +348,28 @@ extends: - script: | ls target/wheels/* + - job: "Course_Notebook_Tests" + displayName: Course notebook tests + pool: + name: "Azure-Pipelines-DevTools-EO" + image: "ubuntu-latest" + os: linux + timeoutInMinutes: 45 + steps: + - task: PipAuthenticate@1 + displayName: Pip Authenticate + inputs: + artifactFeeds: "AzureQuantum/azure-quantum" + + - task: UsePythonVersion@0 + inputs: + versionSpec: "3.11" + githubToken: "$(GH_PACKAGE_READ_TOKEN)" + + - script: | + python ./build.py --no-check --no-check-prereqs --course-notebook-tests + displayName: Test course notebooks + - ${{ each target in parameters.matrix }}: - job: Python_${{ target.name }}_job pool: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d1536285cd..a3776ad13fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,7 +250,7 @@ jobs: echo "run-tests=$run_tests" >> "$GITHUB_OUTPUT" course-notebook-tests: - name: Course notebooks (${{ matrix.course }}, ${{ matrix.arch }}) + name: Course notebooks (${{ matrix.arch }}) needs: course-notebook-changes if: needs.course-notebook-changes.outputs.run-tests == 'true' timeout-minutes: 45 @@ -258,23 +258,19 @@ jobs: fail-fast: false matrix: include: - - course: chemistry-qpe - runner: ubuntu-24.04 + - runner: ubuntu-24.04 arch: x64 machine: x86_64 pip-cache: ~/.cache/pip - - course: chemistry-qpe - runner: ubuntu-24.04-arm + - runner: ubuntu-24.04-arm arch: arm64 machine: aarch64 pip-cache: ~/.cache/pip - - course: chemistry-qpe - runner: windows-2025 + - runner: windows-2025 arch: x64 machine: amd64 pip-cache: ~/AppData/Local/pip/Cache - - course: chemistry-qpe - runner: windows-11-arm + - runner: windows-11-arm arch: arm64 machine: arm64 pip-cache: ~/AppData/Local/pip/Cache @@ -289,17 +285,13 @@ jobs: - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ matrix.pip-cache }} - key: ${{ runner.os }}-${{ matrix.arch }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/requirements.txt', format('source/vscode/resources/qdk-learning/courses/{0}/requirements.txt', matrix.course)) }} + key: ${{ runner.os }}-${{ matrix.arch }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/test_requirements.txt', 'source/vscode/resources/qdk-learning/courses/*/requirements.txt') }} restore-keys: | ${{ runner.os }}-${{ matrix.arch }}-pip- - name: Verify runner architecture run: python -c "import platform; actual = platform.machine().lower(); expected = '${{ matrix.machine }}'; print(f'{actual=} {expected=}'); assert actual == expected" - - name: Install notebook dependencies - run: | - python -m pip install -r source/vscode/test/course-notebooks/requirements.txt - python -m pip install -r source/vscode/resources/qdk-learning/courses/${{ matrix.course }}/requirements.txt - name: Test course notebooks - run: python -m pytest source/vscode/test/course-notebooks -v -s --course ${{ matrix.course }} + run: python ./build.py --no-check --no-check-prereqs --course-notebook-tests status-check: name: Status Check diff --git a/build.py b/build.py index 285f026aa78..a20855daabc 100755 --- a/build.py +++ b/build.py @@ -76,6 +76,13 @@ help="Build and run the integration tests (default is --no-integration-tests)", ) +parser.add_argument( + "--course-notebook-tests", + action=argparse.BooleanOptionalAction, + default=False, + help="Run the QDK learning course notebook tests (default is --no-course-notebook-tests)", +) + parser.add_argument( "--ci-bench", action=argparse.BooleanOptionalAction, @@ -110,6 +117,7 @@ not args.cli and not args.widgets and not args.qdk + and not args.course_notebook_tests # No build required and not args.wasm and not args.npm and not args.play @@ -171,6 +179,9 @@ def step_end(): raw_wheels_dir = os.path.join(root_dir, "target", "raw_wheels") vscode_src = os.path.join(qdk_src_dir, "vscode") jupyterlab_src = os.path.join(qdk_src_dir, "jupyterlab") +course_notebook_tests_dir = os.path.join( + qdk_src_dir, "vscode", "test", "course-notebooks" +) QISKIT_VERSION_MATRIX = [ { @@ -445,12 +456,12 @@ def install_python_test_requirements(cwd, interpreter, check: bool = True): subprocess.run(command_args, check=check, text=True, cwd=cwd) -def run_python_tests(cwd, interpreter, pip_env): +def run_python_tests(cwd, interpreter, pip_env, *pytest_args): test_env = pip_env.copy() if args.gpu_tests: test_env["QDK_GPU_TESTS"] = "1" - command_args = [interpreter, "-m", "pytest"] + command_args = [interpreter, "-m", "pytest", *pytest_args] subprocess.run(command_args, check=True, text=True, cwd=cwd, env=test_env) @@ -590,6 +601,19 @@ def run_ci_historic_benchmark(): step_end() +if args.course_notebook_tests: + python_bin, pip_env = use_python_env(course_notebook_tests_dir) + + step_start("Installing course notebook test requirements") + install_python_test_requirements(course_notebook_tests_dir, python_bin) + step_end() + + step_start("Testing course notebooks") + # Suppress output capturing since it's useful to have the timing information for successful runs too + run_python_tests(course_notebook_tests_dir, python_bin, pip_env, "-v", "-s") + step_end() + + if build_widgets: step_start("Building the Python widgets") diff --git a/source/vscode/test/course-notebooks/README.md b/source/vscode/test/course-notebooks/README.md index 1d95190d30d..a9e96c5aa64 100644 --- a/source/vscode/test/course-notebooks/README.md +++ b/source/vscode/test/course-notebooks/README.md @@ -1,32 +1,34 @@ # Course notebook tests These tests execute the source notebooks for one QDK learning course in fresh -Python kernels. Each notebook runs from an isolated copy of its full course -directory so relative imports work and generated files do not modify the source -tree. +Python kernels. The notebooks run from one shared copy of the full course +directory so relative imports work, generated files do not modify the source +tree, and later notebooks see filesystem state produced by earlier notebooks. + +Pytest creates a `.venv` inside the temporary course copy and installs the +course requirements there. Notebook kernels use that environment, while pytest +continues to use the component's test environment. ## Local setup -Create a Python 3.11 environment and install the test harness plus the selected -course's dependencies: +Run the course suite from the repository root with Python 3.11 or later: ```shell -python3.11 -m venv .venv-course-notebooks -source .venv-course-notebooks/bin/activate -python -m pip install -r source/vscode/test/course-notebooks/requirements.txt -python -m pip install -r source/vscode/resources/qdk-learning/courses/chemistry-qpe/requirements.txt +python ./build.py --no-check --no-check-prereqs --course-notebook-tests ``` -Run the course suite from the repository root: +Like `--integration-tests`, `--course-notebook-tests` runs independently of +the regular `--test`/`--no-test` option. -```shell -python -m pytest source/vscode/test/course-notebooks -v -s --course chemistry-qpe -``` +Following the other Python test suites, `build.py` uses an active Python +environment when available. Otherwise, it creates +`source/vscode/test/course-notebooks/.venv` and installs the test requirements +there. -Run only the fast runner policy tests without installing course dependencies: +Run only the fast runner policy tests without creating a course environment: ```shell -python -m pytest source/vscode/test/course-notebooks/test_notebook_runner.py -v +source/vscode/test/course-notebooks/.venv/bin/python -m pytest source/vscode/test/course-notebooks/test_notebook_runner.py -v ``` ## Cell metadata diff --git a/source/vscode/test/course-notebooks/conftest.py b/source/vscode/test/course-notebooks/conftest.py index 5b69fbb2611..b9be38a6cef 100644 --- a/source/vscode/test/course-notebooks/conftest.py +++ b/source/vscode/test/course-notebooks/conftest.py @@ -1,6 +1,10 @@ from __future__ import annotations +import json +import os import shutil +import subprocess +import venv from pathlib import Path import pytest @@ -11,53 +15,84 @@ COURSES_ROOT = REPO_ROOT / "source/vscode/resources/qdk-learning/courses" -def pytest_addoption(parser: pytest.Parser) -> None: - parser.addoption( - "--course", - help="Immediate child of the QDK learning courses directory to test", - ) - - def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: if "course_notebook" not in metafunc.fixturenames: return - course_dir = _selected_course_dir(metafunc.config) - notebooks = discover_notebooks(course_dir) + notebooks = [ + notebook + for course_dir in _course_dirs() + for notebook in discover_notebooks(course_dir) + ] if not notebooks: - raise pytest.UsageError(f"no source notebooks found under {course_dir}") + raise pytest.UsageError(f"no source notebooks found under {COURSES_ROOT}") metafunc.parametrize( "course_notebook", notebooks, - ids=[str(path.relative_to(course_dir)) for path in notebooks], + ids=[str(path.relative_to(COURSES_ROOT)) for path in notebooks], ) +@pytest.fixture(scope="session") +def copied_course_dirs( + tmp_path_factory: pytest.TempPathFactory, +) -> dict[Path, Path]: + copies_root = tmp_path_factory.mktemp("course-notebooks") + copied_course_dirs = {} + for course_dir in _course_dirs(): + copied_course_dir = copies_root / course_dir.name + shutil.copytree(course_dir, copied_course_dir) + _create_notebook_environment(copied_course_dir) + copied_course_dirs[course_dir] = copied_course_dir + return copied_course_dirs + + @pytest.fixture -def isolated_course_notebook( +def copied_course_notebook( course_notebook: Path, - tmp_path: Path, -) -> tuple[Path, Path]: - course_dir = course_notebook.parents[1] - copied_course_dir = tmp_path / course_dir.name - shutil.copytree(course_dir, copied_course_dir) + copied_course_dirs: dict[Path, Path], +) -> tuple[Path, Path, Path]: + course_name = course_notebook.relative_to(COURSES_ROOT).parts[0] + course_dir = COURSES_ROOT / course_name + copied_course_dir = copied_course_dirs[course_dir] copied_notebook = copied_course_dir / course_notebook.relative_to(course_dir) - return copied_notebook, course_notebook.relative_to(REPO_ROOT) + kernel_specs_dir = copied_course_dir / ".venv" / "share" / "jupyter" / "kernels" + return ( + copied_notebook, + course_notebook.relative_to(REPO_ROOT), + kernel_specs_dir, + ) -def _selected_course_dir(config: pytest.Config) -> Path: - course_name = config.getoption("course") - if not course_name: - raise pytest.UsageError( - "course notebook tests require --course, for example " - "--course chemistry-qpe" - ) - if Path(course_name).name != course_name or "\\" in course_name: - raise pytest.UsageError( - f"--course must name one immediate child of {COURSES_ROOT}" - ) +def _create_notebook_environment(course_dir: Path) -> None: + venv_dir = course_dir / ".venv" + venv.create(venv_dir, with_pip=True) + python = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + subprocess.run( + [ + python, + "-m", + "pip", + "install", + "--quiet", + "-r", + course_dir / "requirements.txt", + ], + check=True, + ) - course_dir = COURSES_ROOT / course_name - if not course_dir.is_dir(): - raise pytest.UsageError(f"course directory does not exist: {course_dir}") - return course_dir \ No newline at end of file + kernel_dir = venv_dir / "share/jupyter/kernels/python3" + kernel_dir.mkdir(parents=True, exist_ok=True) + kernel_spec = { + "argv": [str(python), "-m", "ipykernel_launcher", "-f", "{connection_file}"], + "display_name": "Course notebook tests", + "language": "python", + } + (kernel_dir / "kernel.json").write_text( + json.dumps(kernel_spec, indent=2), + encoding="utf-8", + ) + + +def _course_dirs() -> list[Path]: + return sorted(path for path in COURSES_ROOT.iterdir() if path.is_dir()) diff --git a/source/vscode/test/course-notebooks/notebook_runner.py b/source/vscode/test/course-notebooks/notebook_runner.py index 6eeee993e16..c640839ebca 100644 --- a/source/vscode/test/course-notebooks/notebook_runner.py +++ b/source/vscode/test/course-notebooks/notebook_runner.py @@ -7,6 +7,8 @@ from typing import Any import nbformat +from jupyter_client import AsyncKernelManager +from jupyter_client.kernelspec import KernelSpecManager from nbclient import NotebookClient CELL_TIMEOUT_SECONDS = 120 @@ -118,7 +120,11 @@ def collect_cell_failures(notebook: Any) -> list[CellFailure]: return failures -def run_notebook(notebook_path: Path, display_path: Path | None = None) -> NotebookRunReport: +def run_notebook( + notebook_path: Path, + display_path: Path, + kernel_specs_dir: Path, +) -> NotebookRunReport: notebook = nbformat.read(notebook_path, as_version=4) clear_notebook_outputs(notebook) @@ -129,7 +135,7 @@ def run_notebook(notebook_path: Path, display_path: Path | None = None) -> Noteb ] if metadata_failures: return NotebookRunReport( - display_path or notebook_path, + display_path, 0.0, 0, (), @@ -138,15 +144,22 @@ def run_notebook(notebook_path: Path, display_path: Path | None = None) -> Noteb ) started = perf_counter() + kernel_manager = AsyncKernelManager( + kernel_name="python3", + kernel_spec_manager=KernelSpecManager( + kernel_dirs=[str(kernel_specs_dir)], + ), + ) NotebookClient( notebook, + km=kernel_manager, timeout=CELL_TIMEOUT_SECONDS, allow_errors=True, kernel_name="python3", resources={"metadata": {"path": str(notebook_path.parent)}}, skip_cells_with_tag=SKIP_TEST_TAG, store_widget_state=False, - ).execute() + ).execute(cleanup_kc=True) elapsed_seconds = perf_counter() - started skipped_cells = tuple( @@ -162,10 +175,11 @@ def run_notebook(notebook_path: Path, display_path: Path | None = None) -> Noteb executed_cells = sum( 1 for cell in notebook.cells - if cell.cell_type == "code" and SKIP_TEST_TAG not in cell.metadata.get("tags", []) + if cell.cell_type == "code" + and SKIP_TEST_TAG not in cell.metadata.get("tags", []) ) report = NotebookRunReport( - display_path or notebook_path, + display_path, elapsed_seconds, executed_cells, skipped_cells, @@ -223,4 +237,4 @@ def _slow_cell(cell_number: int, cell: Any) -> SlowCell | None: def _parse_timestamp(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) \ No newline at end of file + return datetime.fromisoformat(value.replace("Z", "+00:00")) diff --git a/source/vscode/test/course-notebooks/test_course_notebooks.py b/source/vscode/test/course-notebooks/test_course_notebooks.py index 153fc4bf322..97069b69f6f 100644 --- a/source/vscode/test/course-notebooks/test_course_notebooks.py +++ b/source/vscode/test/course-notebooks/test_course_notebooks.py @@ -3,9 +3,9 @@ from notebook_runner import run_notebook -def test_course_notebook(isolated_course_notebook: tuple[Path, Path]) -> None: - notebook_path, display_path = isolated_course_notebook +def test_course_notebook(copied_course_notebook: tuple[Path, Path, Path]) -> None: + notebook_path, display_path, kernel_specs_dir = copied_course_notebook - report = run_notebook(notebook_path, display_path) + report = run_notebook(notebook_path, display_path, kernel_specs_dir) - assert not report.failures, report.format_failures() \ No newline at end of file + assert not report.failures, report.format_failures() diff --git a/source/vscode/test/course-notebooks/requirements.txt b/source/vscode/test/course-notebooks/test_requirements.txt similarity index 72% rename from source/vscode/test/course-notebooks/requirements.txt rename to source/vscode/test/course-notebooks/test_requirements.txt index 95aae8f178a..4386aee0934 100644 --- a/source/vscode/test/course-notebooks/requirements.txt +++ b/source/vscode/test/course-notebooks/test_requirements.txt @@ -1,3 +1,3 @@ nbclient nbformat -pytest \ No newline at end of file +pytest From 17afbe7b8b07ac656c633b6ed673d1ebcb0154e3 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 11:13:37 -0700 Subject: [PATCH 03/10] Allow exercise cells to be skipped --- source/vscode/test/course-notebooks/README.md | 7 +++--- .../test/course-notebooks/notebook_runner.py | 24 ------------------- .../course-notebooks/test_notebook_runner.py | 7 ++---- 3 files changed, 6 insertions(+), 32 deletions(-) diff --git a/source/vscode/test/course-notebooks/README.md b/source/vscode/test/course-notebooks/README.md index a9e96c5aa64..8aec68cc633 100644 --- a/source/vscode/test/course-notebooks/README.md +++ b/source/vscode/test/course-notebooks/README.md @@ -37,10 +37,11 @@ source/vscode/test/course-notebooks/.venv/bin/python -m pytest source/vscode/tes successful execution, fails the test. - A `solution` code cell must execute without an error. - A `skip-test` code cell is skipped only by this test suite. VS Code, Jupyter, - and ordinary notebook execution do not interpret this custom tag. + and ordinary notebook execution do not interpret this custom tag. It also + takes precedence when combined with `exercise`. -Do not combine `exercise` and `skip-test`. When a skipped setup cell supplies -state to later cells, tag those dependent cells with `skip-test` as well. +When a skipped setup cell supplies state to later cells, tag those dependent +cells with `skip-test` as well. Each cell has a 120-second timeout. Cells taking longer than 30 seconds are reported so expensive cells can be reviewed before adding a skip. diff --git a/source/vscode/test/course-notebooks/notebook_runner.py b/source/vscode/test/course-notebooks/notebook_runner.py index c640839ebca..8a5d8e7c7e8 100644 --- a/source/vscode/test/course-notebooks/notebook_runner.py +++ b/source/vscode/test/course-notebooks/notebook_runner.py @@ -73,15 +73,6 @@ def collect_cell_failures(notebook: Any) -> list[CellFailure]: tags = set(cell.metadata.get("tags", [])) source_line = _first_source_line(cell.source) - if EXERCISE_TAG in tags and SKIP_TEST_TAG in tags: - failures.append( - CellFailure( - cell_number, - source_line, - f"a cell cannot have both {EXERCISE_TAG!r} and {SKIP_TEST_TAG!r}", - ) - ) - continue if SKIP_TEST_TAG in tags: continue @@ -128,21 +119,6 @@ def run_notebook( notebook = nbformat.read(notebook_path, as_version=4) clear_notebook_outputs(notebook) - metadata_failures = [ - failure - for failure in collect_cell_failures(notebook) - if "cannot have both" in failure.message - ] - if metadata_failures: - return NotebookRunReport( - display_path, - 0.0, - 0, - (), - (), - tuple(metadata_failures), - ) - started = perf_counter() kernel_manager = AsyncKernelManager( kernel_name="python3", diff --git a/source/vscode/test/course-notebooks/test_notebook_runner.py b/source/vscode/test/course-notebooks/test_notebook_runner.py index cf52ebe3815..97e3decc155 100644 --- a/source/vscode/test/course-notebooks/test_notebook_runner.py +++ b/source/vscode/test/course-notebooks/test_notebook_runner.py @@ -61,10 +61,7 @@ def test_skip_test_cell_is_not_evaluated(): assert collect_cell_failures(notebook) == [] -def test_exercise_cannot_be_skipped(): +def test_skipped_exercise_is_not_evaluated(): notebook = _notebook(_code_cell(tags=["exercise", "skip-test"])) - failures = collect_cell_failures(notebook) - - assert len(failures) == 1 - assert failures[0].message == "a cell cannot have both 'exercise' and 'skip-test'" \ No newline at end of file + assert collect_cell_failures(notebook) == [] \ No newline at end of file From 1c571a3a06c3cba74063acac97b48e96d5d34205 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 11:22:34 -0700 Subject: [PATCH 04/10] Fix types in notebook_runner.py --- .../test/course-notebooks/notebook_runner.py | 16 +++++++++------- .../course-notebooks/test_notebook_runner.py | 14 ++++++++++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/source/vscode/test/course-notebooks/notebook_runner.py b/source/vscode/test/course-notebooks/notebook_runner.py index 8a5d8e7c7e8..b72c228a907 100644 --- a/source/vscode/test/course-notebooks/notebook_runner.py +++ b/source/vscode/test/course-notebooks/notebook_runner.py @@ -4,12 +4,12 @@ from datetime import datetime from pathlib import Path from time import perf_counter -from typing import Any import nbformat -from jupyter_client import AsyncKernelManager +from jupyter_client.manager import AsyncKernelManager from jupyter_client.kernelspec import KernelSpecManager from nbclient import NotebookClient +from nbformat import NotebookNode CELL_TIMEOUT_SECONDS = 120 SLOW_CELL_SECONDS = 30 @@ -56,7 +56,7 @@ def discover_notebooks(course_dir: Path) -> list[Path]: ) -def clear_notebook_outputs(notebook: Any) -> None: +def clear_notebook_outputs(notebook: NotebookNode) -> None: for cell in notebook.cells: if cell.cell_type != "code": continue @@ -65,7 +65,7 @@ def clear_notebook_outputs(notebook: Any) -> None: cell.metadata.pop("execution", None) -def collect_cell_failures(notebook: Any) -> list[CellFailure]: +def collect_cell_failures(notebook: NotebookNode) -> list[CellFailure]: failures: list[CellFailure] = [] for cell_number, cell in enumerate(notebook.cells, start=1): if cell.cell_type != "code": @@ -143,6 +143,8 @@ def run_notebook( for cell_number, cell in enumerate(notebook.cells, start=1) if cell.cell_type == "code" and SKIP_TEST_TAG in cell.metadata.get("tags", []) ) + # Cells that are slow enough to consider skipping, but not slow enough to fail with a timeout. + # Reported as a convenience for test authors. slow_cells = tuple( slow_cell for cell_number, cell in enumerate(notebook.cells, start=1) @@ -187,13 +189,13 @@ def _first_source_line(source: str) -> str: return "" -def _format_error(error: Any) -> str: +def _format_error(error: NotebookNode) -> str: name = error.get("ename", "Error") value = error.get("evalue", "") return f"{name}: {value}" if value else name -def _slow_cell(cell_number: int, cell: Any) -> SlowCell | None: +def _slow_cell(cell_number: int, cell: NotebookNode) -> SlowCell | None: if cell.cell_type != "code": return None execution = cell.metadata.get("execution", {}) @@ -213,4 +215,4 @@ def _slow_cell(cell_number: int, cell: Any) -> SlowCell | None: def _parse_timestamp(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")) + return datetime.fromisoformat(value) diff --git a/source/vscode/test/course-notebooks/test_notebook_runner.py b/source/vscode/test/course-notebooks/test_notebook_runner.py index 97e3decc155..a661e38e926 100644 --- a/source/vscode/test/course-notebooks/test_notebook_runner.py +++ b/source/vscode/test/course-notebooks/test_notebook_runner.py @@ -23,7 +23,9 @@ def _code_cell(*, tags=(), error=None): def test_exercise_requires_exercise_error(): - notebook = _notebook(_code_cell(tags=["exercise"], error=("ExerciseError", "try again"))) + notebook = _notebook( + _code_cell(tags=["exercise"], error=("ExerciseError", "try again")) + ) assert collect_cell_failures(notebook) == [] @@ -38,7 +40,9 @@ def test_exercise_that_succeeds_fails_policy(): def test_exercise_with_wrong_error_fails_policy(): - notebook = _notebook(_code_cell(tags=["exercise"], error=("ValueError", "bad value"))) + notebook = _notebook( + _code_cell(tags=["exercise"], error=("ValueError", "bad value")) + ) failures = collect_cell_failures(notebook) @@ -56,7 +60,9 @@ def test_ordinary_cell_error_fails_policy(): def test_skip_test_cell_is_not_evaluated(): - notebook = _notebook(_code_cell(tags=["skip-test"], error=("RuntimeError", "ignored"))) + notebook = _notebook( + _code_cell(tags=["skip-test"], error=("RuntimeError", "ignored")) + ) assert collect_cell_failures(notebook) == [] @@ -64,4 +70,4 @@ def test_skip_test_cell_is_not_evaluated(): def test_skipped_exercise_is_not_evaluated(): notebook = _notebook(_code_cell(tags=["exercise", "skip-test"])) - assert collect_cell_failures(notebook) == [] \ No newline at end of file + assert collect_cell_failures(notebook) == [] From b45e37ae420a4debae5f29a707f457414e520b21 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 11:28:36 -0700 Subject: [PATCH 05/10] Validate course.json --- source/vscode/test/course-notebooks/README.md | 2 ++ .../vscode/test/course-notebooks/conftest.py | 10 ++++++- .../course-notebooks/test_course_notebooks.py | 29 ++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/source/vscode/test/course-notebooks/README.md b/source/vscode/test/course-notebooks/README.md index 8aec68cc633..3e6eaf414e5 100644 --- a/source/vscode/test/course-notebooks/README.md +++ b/source/vscode/test/course-notebooks/README.md @@ -4,6 +4,8 @@ These tests execute the source notebooks for one QDK learning course in fresh Python kernels. The notebooks run from one shared copy of the full course directory so relative imports work, generated files do not modify the source tree, and later notebooks see filesystem state produced by earlier notebooks. +The suite also verifies that each notebook corresponds one-to-one with a unit +directory listed in the course's `course.json`. Pytest creates a `.venv` inside the temporary course copy and installs the course requirements there. Notebook kernels use that environment, while pytest diff --git a/source/vscode/test/course-notebooks/conftest.py b/source/vscode/test/course-notebooks/conftest.py index b9be38a6cef..4437ef1f8f4 100644 --- a/source/vscode/test/course-notebooks/conftest.py +++ b/source/vscode/test/course-notebooks/conftest.py @@ -16,12 +16,20 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + course_dirs = _course_dirs() + if "course_dir" in metafunc.fixturenames: + metafunc.parametrize( + "course_dir", + course_dirs, + ids=[path.name for path in course_dirs], + ) + if "course_notebook" not in metafunc.fixturenames: return notebooks = [ notebook - for course_dir in _course_dirs() + for course_dir in course_dirs for notebook in discover_notebooks(course_dir) ] if not notebooks: diff --git a/source/vscode/test/course-notebooks/test_course_notebooks.py b/source/vscode/test/course-notebooks/test_course_notebooks.py index 97069b69f6f..46ac9d23a78 100644 --- a/source/vscode/test/course-notebooks/test_course_notebooks.py +++ b/source/vscode/test/course-notebooks/test_course_notebooks.py @@ -1,6 +1,33 @@ +import json +from collections import Counter from pathlib import Path -from notebook_runner import run_notebook +from notebook_runner import discover_notebooks, run_notebook + + +def test_discovered_notebooks_match_course_manifest(course_dir: Path) -> None: + manifest = json.loads((course_dir / "course.json").read_text(encoding="utf-8")) + manifest_unit_dirs = [Path(unit["dir"]) for unit in manifest["units"]] + discovered_notebooks = discover_notebooks(course_dir) + discovered_unit_dirs = [ + notebook.relative_to(course_dir).parent for notebook in discovered_notebooks + ] + + manifest_counts = Counter(manifest_unit_dirs) + discovered_counts = Counter(discovered_unit_dirs) + missing = list((manifest_counts - discovered_counts).elements()) + unmatched = [ + notebook.relative_to(course_dir) + for notebook in discovered_notebooks + if discovered_counts[notebook.relative_to(course_dir).parent] + > manifest_counts[notebook.relative_to(course_dir).parent] + ] + + assert manifest_counts == discovered_counts, ( + f"{course_dir / 'course.json'} does not match discovered notebooks; " + f"missing notebooks for units: {missing}; " + f"unmatched notebooks: {unmatched}" + ) def test_course_notebook(copied_course_notebook: tuple[Path, Path, Path]) -> None: From 82266d5d017b7a2253a4efc636e18b61aa66ec17 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 11:37:32 -0700 Subject: [PATCH 06/10] Add type annotations --- .../course-notebooks/test_notebook_runner.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/source/vscode/test/course-notebooks/test_notebook_runner.py b/source/vscode/test/course-notebooks/test_notebook_runner.py index a661e38e926..9745acea72d 100644 --- a/source/vscode/test/course-notebooks/test_notebook_runner.py +++ b/source/vscode/test/course-notebooks/test_notebook_runner.py @@ -1,13 +1,20 @@ +from collections.abc import Iterable + import nbformat +from nbformat import NotebookNode from notebook_runner import collect_cell_failures -def _notebook(*cells): +def _notebook(*cells: NotebookNode) -> NotebookNode: return nbformat.v4.new_notebook(cells=list(cells)) -def _code_cell(*, tags=(), error=None): +def _code_cell( + *, + tags: Iterable[str] = (), + error: tuple[str, str] | None = None, +) -> NotebookNode: cell = nbformat.v4.new_code_cell("answer = 42", metadata={"tags": list(tags)}) if error is not None: name, value = error @@ -22,7 +29,7 @@ def _code_cell(*, tags=(), error=None): return cell -def test_exercise_requires_exercise_error(): +def test_exercise_requires_exercise_error() -> None: notebook = _notebook( _code_cell(tags=["exercise"], error=("ExerciseError", "try again")) ) @@ -30,7 +37,7 @@ def test_exercise_requires_exercise_error(): assert collect_cell_failures(notebook) == [] -def test_exercise_that_succeeds_fails_policy(): +def test_exercise_that_succeeds_fails_policy() -> None: notebook = _notebook(_code_cell(tags=["exercise"])) failures = collect_cell_failures(notebook) @@ -39,7 +46,7 @@ def test_exercise_that_succeeds_fails_policy(): assert failures[0].message == "exercise cell did not raise ExerciseError" -def test_exercise_with_wrong_error_fails_policy(): +def test_exercise_with_wrong_error_fails_policy() -> None: notebook = _notebook( _code_cell(tags=["exercise"], error=("ValueError", "bad value")) ) @@ -50,7 +57,7 @@ def test_exercise_with_wrong_error_fails_policy(): assert failures[0].message == "exercise cell raised ValueError: bad value" -def test_ordinary_cell_error_fails_policy(): +def test_ordinary_cell_error_fails_policy() -> None: notebook = _notebook(_code_cell(error=("RuntimeError", "broken"))) failures = collect_cell_failures(notebook) @@ -59,7 +66,7 @@ def test_ordinary_cell_error_fails_policy(): assert failures[0].message == "unexpected error: RuntimeError: broken" -def test_skip_test_cell_is_not_evaluated(): +def test_skip_test_cell_is_not_evaluated() -> None: notebook = _notebook( _code_cell(tags=["skip-test"], error=("RuntimeError", "ignored")) ) @@ -67,7 +74,7 @@ def test_skip_test_cell_is_not_evaluated(): assert collect_cell_failures(notebook) == [] -def test_skipped_exercise_is_not_evaluated(): +def test_skipped_exercise_is_not_evaluated() -> None: notebook = _notebook(_code_cell(tags=["exercise", "skip-test"])) assert collect_cell_failures(notebook) == [] From 4b0ea134388a45ffa5d8f68c0b5b1b567ad8ef45 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 13:03:26 -0700 Subject: [PATCH 07/10] Tidy up build_all logic so that notebook tests don't interfere with integration tests --- build.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/build.py b/build.py index a20855daabc..026bab13e3e 100755 --- a/build.py +++ b/build.py @@ -112,19 +112,25 @@ args = parser.parse_args() -# If no specific project given then build all -build_all = ( - not args.cli - and not args.widgets - and not args.qdk - and not args.course_notebook_tests # No build required - and not args.wasm - and not args.npm - and not args.play - and not args.vscode - and not args.jupyterlab - and not args.ci_bench +specific_project_requested = any( + ( + args.cli, + args.widgets, + args.qdk, + args.wasm, + args.npm, + args.play, + args.vscode, + args.jupyterlab, + ) ) + +standalone_action_requested = args.course_notebook_tests or args.ci_bench + +build_all = not specific_project_requested and ( + args.integration_tests or not standalone_action_requested +) + build_cli = build_all or args.cli build_widgets = build_all or args.widgets build_qdk = build_all or args.qdk From 1dbee90cd63c0206ee825fe9dd7507ca923f071f Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 13:37:31 -0700 Subject: [PATCH 08/10] Don't explicitly validate that GH gave use the right machine architecture --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3776ad13fa..11340465e7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,19 +260,15 @@ jobs: include: - runner: ubuntu-24.04 arch: x64 - machine: x86_64 pip-cache: ~/.cache/pip - runner: ubuntu-24.04-arm arch: arm64 - machine: aarch64 pip-cache: ~/.cache/pip - runner: windows-2025 arch: x64 - machine: amd64 pip-cache: ~/AppData/Local/pip/Cache - runner: windows-11-arm arch: arm64 - machine: arm64 pip-cache: ~/AppData/Local/pip/Cache runs-on: ${{ matrix.runner }} @@ -288,8 +284,6 @@ jobs: key: ${{ runner.os }}-${{ matrix.arch }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/test_requirements.txt', 'source/vscode/resources/qdk-learning/courses/*/requirements.txt') }} restore-keys: | ${{ runner.os }}-${{ matrix.arch }}-pip- - - name: Verify runner architecture - run: python -c "import platform; actual = platform.machine().lower(); expected = '${{ matrix.machine }}'; print(f'{actual=} {expected=}'); assert actual == expected" - name: Test course notebooks run: python ./build.py --no-check --no-check-prereqs --course-notebook-tests From 0647283bb76e2f2a84d78d192ed5374f7653d487 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 13:55:24 -0700 Subject: [PATCH 09/10] Move platform matrix out of ci.yml --- .ado/publish.yml | 26 ++++---------------------- .github/workflows/ci.yml | 27 +++++---------------------- .github/workflows/multiplat.yml | 2 ++ 3 files changed, 11 insertions(+), 44 deletions(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index 7ecf3e6ffd9..f0a3d662dd3 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -348,28 +348,6 @@ extends: - script: | ls target/wheels/* - - job: "Course_Notebook_Tests" - displayName: Course notebook tests - pool: - name: "Azure-Pipelines-DevTools-EO" - image: "ubuntu-latest" - os: linux - timeoutInMinutes: 45 - steps: - - task: PipAuthenticate@1 - displayName: Pip Authenticate - inputs: - artifactFeeds: "AzureQuantum/azure-quantum" - - - task: UsePythonVersion@0 - inputs: - versionSpec: "3.11" - githubToken: "$(GH_PACKAGE_READ_TOKEN)" - - - script: | - python ./build.py --no-check --no-check-prereqs --course-notebook-tests - displayName: Test course notebooks - - ${{ each target in parameters.matrix }}: - job: Python_${{ target.name }}_job pool: @@ -491,6 +469,10 @@ extends: displayName: Build Platform-Dependent Py Packages (Other) condition: not(and(eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['arch'], 'aarch64'))) + - script: | + python ./build.py --no-check --no-check-prereqs --course-notebook-tests + displayName: Test course notebooks + - script: | dir target\wheels\* displayName: List Py Packages on Win diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11340465e7f..243eff4f78f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,40 +250,23 @@ jobs: echo "run-tests=$run_tests" >> "$GITHUB_OUTPUT" course-notebook-tests: - name: Course notebooks (${{ matrix.arch }}) + name: Course notebooks needs: course-notebook-changes if: needs.course-notebook-changes.outputs.run-tests == 'true' timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - arch: x64 - pip-cache: ~/.cache/pip - - runner: ubuntu-24.04-arm - arch: arm64 - pip-cache: ~/.cache/pip - - runner: windows-2025 - arch: x64 - pip-cache: ~/AppData/Local/pip/Cache - - runner: windows-11-arm - arch: arm64 - pip-cache: ~/AppData/Local/pip/Cache - runs-on: ${{ matrix.runner }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.PYTHON_VERSION }} - architecture: ${{ matrix.arch }} - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ${{ matrix.pip-cache }} - key: ${{ runner.os }}-${{ matrix.arch }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/test_requirements.txt', 'source/vscode/resources/qdk-learning/courses/*/requirements.txt') }} + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/test_requirements.txt', 'source/vscode/resources/qdk-learning/courses/*/requirements.txt') }} restore-keys: | - ${{ runner.os }}-${{ matrix.arch }}-pip- + ${{ runner.os }}-pip- - name: Test course notebooks run: python ./build.py --no-check --no-check-prereqs --course-notebook-tests diff --git a/.github/workflows/multiplat.yml b/.github/workflows/multiplat.yml index 24a0b2eb6c9..e0598c3f0e6 100644 --- a/.github/workflows/multiplat.yml +++ b/.github/workflows/multiplat.yml @@ -59,6 +59,8 @@ jobs: - name: Build and Test run: python ./build.py --integration-tests if: runner.os != 'Linux' + - name: Test course notebooks + run: python ./build.py --no-check --no-check-prereqs --course-notebook-tests - name: File issue on failure if: ${{ failure() && github.event_name != 'workflow_dispatch' }} id: create-issue From e0d7fa0eb65a50cdd3531878e4146a12df0a8e83 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Mon, 14 Sep 2026 14:30:26 -0700 Subject: [PATCH 10/10] Use setup-python caching --- .github/workflows/ci.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 243eff4f78f..4a5a1f836a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,12 +261,10 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.PYTHON_VERSION }} - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('source/vscode/test/course-notebooks/test_requirements.txt', 'source/vscode/resources/qdk-learning/courses/*/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- + cache: pip + cache-dependency-path: | + source/vscode/test/course-notebooks/test_requirements.txt + source/vscode/resources/qdk-learning/courses/*/requirements.txt - name: Test course notebooks run: python ./build.py --no-check --no-check-prereqs --course-notebook-tests