diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 02b7d41..ce92079 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -15,6 +15,7 @@ on: jobs: build: + timeout-minutes: 20 runs-on: ${{ format('{0}-latest', matrix.os) }} strategy: fail-fast: false @@ -26,9 +27,9 @@ jobs: python-version: ${{ format('{0}.{1}', matrix.python-major-version, matrix.python-minor-version) }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ env.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ env.python-version }} - name: Install uv and Nox @@ -36,6 +37,7 @@ jobs: python -m pip install --upgrade pip python -m pip install uv nox - name: Run tests using Nox + timeout-minutes: 15 # Nox creates the per-version venv and uses `uv sync --frozen` (single # uv.lock) to install the project and dependencies into it. run: | diff --git a/noxfile.py b/noxfile.py index f60a243..21764a4 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,25 +1,18 @@ import os -import sys import nox # Select the Python versions to test against (these must be installed on the system) python_versions = ['3.10', '3.11', '3.12'] -# Configure radCAD for tests -if sys.platform.startswith('win'): - # Use the multiprocessing backend on Windows to avoid recursion depth errors - # TODO Remove this once the recursion depth issue is resolved - # (literal name of Backend.MULTIPROCESSING; avoids importing radcad here) - os.environ['RADCAD_BACKEND'] = 'MULTIPROCESSING' - - def install_dependencies(session): '''Install the project and its dependencies into the session venv with uv''' + venv = os.path.abspath(session.virtualenv.location) session.run( 'uv', 'sync', '--frozen', + '--python', session.python, '--extra', 'compat', '--extra', 'extension-backend-ray', - env={'UV_PROJECT_ENVIRONMENT': session.virtualenv.location}, + env={'UV_PROJECT_ENVIRONMENT': venv, 'VIRTUAL_ENV': venv}, external=True, ) @@ -27,7 +20,7 @@ def install_dependencies(session): def tests(session): install_dependencies(session) session.run( - 'pytest', + 'python', '-m', 'pytest', # Currently failing on Windows due to recursion depth limit # '-n', 'auto', # Run tests in parallel using pytest-xdist 'tests', @@ -37,7 +30,7 @@ def tests(session): def benchmarks(session): install_dependencies(session) session.run( - 'pytest', + 'python', '-m', 'pytest', 'benchmarks/benchmark_radcad.py', f'--benchmark-save=py{session.python}', ) diff --git a/tests/test_engine.py b/tests/test_engine.py index bf31b42..9914622 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,6 +1,17 @@ from radcad import Model, Simulation, Experiment +from radcad.engine import Engine, Backend from tests.test_cases import basic +def test_radcad_backend_env_sets_default(monkeypatch): + monkeypatch.setenv('RADCAD_BACKEND', 'SINGLE_PROCESS') + assert Engine().backend == Backend.SINGLE_PROCESS + + +def test_radcad_backend_env_overrides_explicit_backend(monkeypatch): + monkeypatch.setenv('RADCAD_BACKEND', 'SINGLE_PROCESS') + assert Engine(backend=Backend.PATHOS).backend == Backend.SINGLE_PROCESS + + def test_run(): states = basic.states state_update_blocks = basic.state_update_blocks diff --git a/tests/test_examples.py b/tests/test_examples.py index e54eeb9..e65ba95 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,19 +1,85 @@ -import pytest import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + + +NOTEBOOK_TIMEOUT_SECONDS = 180 + + +def _tail(text, limit=4000): + if isinstance(text, bytes): + text = text.decode(errors="replace") + return text[-limit:] if text else "" + def check_notebook(notebook): - result = os.popen(f"jupyter nbconvert --to script --execute --stdout {notebook} | ipython").read() - return "1" in result + notebook = Path(notebook) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir = Path(temp_dir) + work_dir = temp_dir / notebook.parent.name + shutil.copytree(notebook.parent, work_dir) + output_dir = temp_dir / "executed" + output_dir.mkdir() + notebook = work_dir / notebook.name + + env = os.environ.copy() + # Force notebooks to single-process execution. Windows multiprocessing + # spawn cannot import notebook-defined functions, and PATHOS/dill has + # historically hit recursion/stack limits with notebook globals on CI. + env["RADCAD_BACKEND"] = "SINGLE_PROCESS" + command = [ + sys.executable, + "-m", + "jupyter", + "nbconvert", + "--to", + "notebook", + "--execute", + f"--ExecutePreprocessor.timeout={NOTEBOOK_TIMEOUT_SECONDS}", + "--ExecutePreprocessor.kernel_name=python3", + "--output-dir", + output_dir, + str(notebook), + ] + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=NOTEBOOK_TIMEOUT_SECONDS, + env=env, + ) + except subprocess.TimeoutExpired as exc: + pytest.fail( + f"Notebook timed out after {NOTEBOOK_TIMEOUT_SECONDS}s: {notebook}\n" + f"stdout:\n{_tail(exc.stdout)}\n" + f"stderr:\n{_tail(exc.stderr)}" + ) + + assert result.returncode == 0, ( + f"Notebook failed: {notebook}\n" + f"stdout:\n{_tail(result.stdout)}\n" + f"stderr:\n{_tail(result.stderr)}" + ) + def test_game_of_life(): - assert check_notebook("examples/game_of_life/game-of-life.ipynb") + check_notebook("examples/game_of_life/game-of-life.ipynb") + def test_predator_prey_sd(): - assert check_notebook("examples/predator_prey_sd/predator-prey-sd.ipynb") + check_notebook("examples/predator_prey_sd/predator-prey-sd.ipynb") + def test_predator_prey_abm(): - assert check_notebook("examples/predator_prey_abm/predator-prey-abm.ipynb") + check_notebook("examples/predator_prey_abm/predator-prey-abm.ipynb") -def test_harmonic_oscillator(): - assert check_notebook("examples/harmonic_oscillator/harmonic_oscillator.ipynb") +def test_harmonic_oscillator(): + check_notebook("examples/harmonic_oscillator/harmonic_oscillator.ipynb")