diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e6611c86c..b466c6a8a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -29,7 +29,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip - pip install setuptools build cython pytest pytest-skip-slow wheel lxml numpy scipy + pip install setuptools build cython pytest pytest-skip-slow wheel lxml numpy scipy nbformat nbclient ipykernel - name: Build source distribution run: python -m build @@ -38,7 +38,13 @@ jobs: cd dist pip install -v pygambit*.tar.gz - name: Run tests - run: pytest + run: | + if [ "${{ matrix.python-version }}" = "3.9" ]; then + # Python 3.9 on linux skips the notebook execution test (notebooks may require newer kernels/deps) + pytest -q -k 'not test_execute_notebook' + else + pytest + fi macos-13: runs-on: macos-13 @@ -56,7 +62,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip - pip install cython pytest pytest-skip-slow wheel lxml numpy scipy + pip install cython pytest pytest-skip-slow wheel lxml numpy scipy nbformat nbclient ipykernel - name: Build extension run: | python -m pip install -v . @@ -79,7 +85,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip - pip install cython pytest pytest-skip-slow wheel lxml numpy scipy + pip install cython pytest pytest-skip-slow wheel lxml numpy scipy nbformat nbclient ipykernel - name: Build extension run: | python -m pip install -v . @@ -102,7 +108,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip - pip install cython pytest pytest-skip-slow wheel lxml numpy scipy + pip install cython pytest pytest-skip-slow wheel lxml numpy scipy nbformat nbclient ipykernel - name: Build extension run: | python -m pip install -v . diff --git a/doc/tutorials/running_locally.rst b/doc/tutorials/running_locally.rst index f17eae6e2..075511cec 100644 --- a/doc/tutorials/running_locally.rst +++ b/doc/tutorials/running_locally.rst @@ -4,7 +4,7 @@ How to run PyGambit tutorials on your computer ============================================== The PyGambit tutorials are available as Jupyter notebooks and can be run interactively using any program that supports Jupyter notebooks, such as JupyterLab or VSCode. -You will need a working installation of Python 3 (tested with 3.9 and later) on your machine. +You will need a working installation of Python 3.9+ on your machine to run PyGambit (however the tutorials contain some syntax that may not be compatible with earlier versions of Python than 3.13). 1. To download the tutorials, open your OS's command prompt and clone the Gambit repository from GitHub, then navigate to the tutorials directory: :: diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py new file mode 100644 index 000000000..efe0ab0f2 --- /dev/null +++ b/tests/test_tutorials.py @@ -0,0 +1,55 @@ +import contextlib +from pathlib import Path + +import nbformat +import pytest +from nbclient import NotebookClient +from nbclient.exceptions import CellExecutionError + + +def _find_tutorial_notebooks(): + """Return a sorted list of notebook Paths under doc/tutorials. + + Skips the entire module if the tutorials directory does not exist. + """ + root = Path(__file__).resolve().parents[1] / "doc" / "tutorials" + if not root.exists(): + pytest.skip(f"Tutorials folder not found: {root}") + notebooks = sorted(root.rglob("*.ipynb")) + if not notebooks: + pytest.skip(f"No tutorial notebooks found in: {root}") + return notebooks + + +# Discover notebooks at import time so pytest can parametrize them. +_NOTEBOOKS = _find_tutorial_notebooks() + + +@pytest.mark.parametrize("nb_path", _NOTEBOOKS, ids=[p.name for p in _NOTEBOOKS]) +def test_execute_notebook(nb_path): + """Execute a single Jupyter notebook and fail if any cell errors occur. + + This uses nbclient.NotebookClient to run the notebook in its parent directory + so relative paths within the notebook resolve correctly. + """ + nb = nbformat.read(str(nb_path), as_version=4) + + # Prefer the notebook's kernelspec if provided, otherwise let nbclient pick the default. + kernel_name = nb.metadata.get("kernelspec", {}).get("name") + + client = NotebookClient( + nb, + timeout=600, + kernel_name=kernel_name, + resources={"metadata": {"path": str(nb_path.parent)}}, + ) + + try: + client.execute() + except CellExecutionError as exc: + # Re-raise with more context so pytest shows which notebook failed. + raise AssertionError(f"Error while executing notebook {nb_path}: {exc}") from exc + finally: + # Ensure kernel is shut down. + with contextlib.suppress(Exception): + client.shutdown_kernel() diff --git a/tests/test_userguide.py b/tests/test_userguide.py deleted file mode 100644 index d86547ae6..000000000 --- a/tests/test_userguide.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Tests inspired by examples in the user guide.""" -import pygambit as gbt - - -def test_trust_game(): - """Build the one-shot trust game from Kreps (1990)""" - g = gbt.Game.new_tree(players=["Buyer", "Seller"], - title="One-shot trust game, after Kreps (1990)") - g.append_move(g.root, "Buyer", ["Trust", "Not trust"]) - g.append_move(g.root.children[0], "Seller", ["Honor", "Abuse"]) - g.set_outcome(g.root.children[0].children[0], - g.add_outcome([1, 1], label="Trustworthy")) - g.set_outcome(g.root.children[0].children[1], - g.add_outcome([-1, 2], label="Untrustworthy")) - g.set_outcome(g.root.children[1], - g.add_outcome([0, 0], label="Opt-out")) - - -def test_myerson_poker(): - """Build the one-card poker example adapted from Myerson (1991)""" - g = gbt.Game.new_tree(players=["Alice", "Bob"], - title="One card poker game, after Myerson (1991)") - g.append_move(g.root, g.players.chance, ["King", "Queen"]) - for node in g.root.children: - g.append_move(node, "Alice", ["Raise", "Fold"]) - g.append_move(g.root.children[0].children[0], "Bob", ["Meet", "Pass"]) - g.append_infoset(g.root.children[1].children[0], - g.root.children[0].children[0].infoset) - alice_winsbig = g.add_outcome([2, -2], label="Alice wins big") - alice_wins = g.add_outcome([1, -1], label="Alice wins") - bob_winsbig = g.add_outcome([-2, 2], label="Bob wins big") - bob_wins = g.add_outcome([-1, 1], label="Bob wins") - g.set_outcome(g.root.children[0].children[0].children[0], alice_winsbig) - g.set_outcome(g.root.children[0].children[0].children[1], alice_wins) - g.set_outcome(g.root.children[0].children[1], bob_wins) - g.set_outcome(g.root.children[1].children[0].children[0], bob_winsbig) - g.set_outcome(g.root.children[1].children[0].children[1], alice_wins) - g.set_outcome(g.root.children[1].children[1], bob_wins)