Repository: N-Wouda/ALNS (language: python)

Issue:
Run w/o MABwiser in the CI
See for example #162. It's tricky to verify everything also works without MABwiser. We should do a CI run where it is not installed by default to make sure everything also works without the dependency.

This will probably need to mark some tests as skippable if `mabwiser` is not available.



Discussion / hints:
I guess one run without MABWiser should suffice. We can skip the relevant tests when we do not find MABWiser, using pytest's `skipif` marker.


Fix patch:
diff --git a/.github/workflows/alns.yaml b/.github/workflows/alns.yaml
index 79ec9f8..09b59ff 100644
--- a/.github/workflows/alns.yaml
+++ b/.github/workflows/alns.yaml
@@ -10,10 +10,14 @@ on:
 
 jobs:
   build:
+    name: >
+      Build and test ALNS using Python ${{ matrix.python-version }} and "${{ matrix.extras }}"
     runs-on: ubuntu-latest
     strategy:
+      fail-fast: true
       matrix:
-        python-version: [ '3.9', '3.10', '3.11', '3.12' ]
+        python-version: [ '3.9', '3.12' ]
+        extras: [ '--all-extras', '' ]
     steps:
       - uses: actions/checkout@v4
       - name: Set up Python
@@ -29,10 +33,10 @@ jobs:
         id: cache-python
         with:
           path: ~/.cache/pypoetry
-          key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}
+          key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }}-${{ matrix.extras }}
       - name: Install Python dependencies
         if: steps.cache-python.outputs.cache-hit != 'true'
-        run: poetry install
+        run: poetry install ${{ matrix.extras }}
       - name: Cache pre-commit
         uses: actions/cache@v4
         id: cache-pre-commit
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 0cfcd29..32e6a1c 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -10,7 +10,7 @@ build:
     post_install:
       # VIRTUAL_ENV needs to be set manually for now; see
       # https://github.com/readthedocs/readthedocs.org/issues/11150 for details.
-      - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs,examples
+      - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs,examples --all-extras
 
 sphinx:
   configuration: docs/source/conf.py
diff --git a/docs/source/setup/contributing.rst b/docs/source/setup/contributing.rst
index d1af316..b144973 100644
--- a/docs/source/setup/contributing.rst
+++ b/docs/source/setup/contributing.rst
@@ -22,7 +22,7 @@ Now, change into the ALNS directory, and set-up the virtual environment using ``
    cd ALNS
 
    pip install --upgrade poetry
-   poetry install --with examples
+   poetry install --with examples --all-extras
 
 This might take a few minutes, but only needs to be done once.
 Now make sure everything runs smoothly, by executing the test suite:
diff --git a/docs/source/setup/installation.rst b/docs/source/setup/installation.rst
index 8541b09..05c72e1 100644
--- a/docs/source/setup/installation.rst
+++ b/docs/source/setup/installation.rst
@@ -44,7 +44,7 @@ This goes like so:
 .. code-block::
 
    cd ALNS
-   poetry install --with examples
+   poetry install --with examples --all-extras
 
 This might take a few minutes to resolve, but only needs to be done once.
 After setting everything up, simply open the jupyter notebooks:
diff --git a/pyproject.toml b/pyproject.toml
index 3e8bd27..a26ceff 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,7 +42,6 @@ pre-commit = "^2.20.0"
 pytest = ">=6.0.0"
 pytest-cov = ">=2.6.1"
 codecov = "*"
-mabwiser = ">=2.7.1"
 
 # This optional docs group is needed to build the documentation. It is not 
 # required by the package itself.


Test patch:
diff --git a/alns/select/tests/test_mab_selector.py b/alns/select/tests/test_mab_selector.py
index e326512..1aae373 100644
--- a/alns/select/tests/test_mab_selector.py
+++ b/alns/select/tests/test_mab_selector.py
@@ -1,17 +1,20 @@
 from typing import List
 
 import numpy.random as rnd
-from mabwiser.mab import LearningPolicy, NeighborhoodPolicy
+import pytest
 from numpy.testing import assert_, assert_equal, assert_raises
-from pytest import mark
 
 from alns.Outcome import Outcome
 from alns.select import MABSelector
-from alns.select.MABSelector import arm2ops, ops2arm
+from alns.select.MABSelector import MABWISER_AVAILABLE, arm2ops, ops2arm
 from alns.tests.states import Zero, ZeroWithOneContext, ZeroWithZeroContext
 
+if MABWISER_AVAILABLE:
+    from mabwiser.mab import LearningPolicy, NeighborhoodPolicy
 
-@mark.parametrize(
+
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
+@pytest.mark.parametrize(
     "destroy_idx, repair_idx",
     [
         (0, 0),
@@ -28,6 +31,7 @@ def test_arm_conversion(destroy_idx, repair_idx):
     assert_equal(actual, expected)
 
 
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
 def test_does_not_raise_on_valid_mab():
     policy = LearningPolicy.EpsilonGreedy(0.15)
     select = MABSelector([5, 0, 3, 0], 2, 1, policy)
@@ -41,33 +45,25 @@ def test_does_not_raise_on_valid_mab():
     MABSelector([2, 1, 0, 0], 2, 1, policy, seed=1234567)
 
 
-@mark.parametrize(
-    "scores, learning_policy, num_destroy, num_repair",
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
+@pytest.mark.parametrize(
+    "scores, num_destroy, num_repair",
     [
-        (
-            [5, 3, 2, -1],
-            LearningPolicy.EpsilonGreedy(0.15),
-            1,
-            1,
-        ),  # negative score
-        (
-            [5, 3, 2],
-            LearningPolicy.EpsilonGreedy(0.15),
-            1,
-            1,
-        ),  # len(score) < 4
+        ([5, 3, 2, -1], 1, 1),  # negative score
+        ([5, 3, 2], 1, 1),  # len(score) < 4
     ],
 )
 def test_raises_invalid_arguments(
     scores: List[float],
-    learning_policy: LearningPolicy,
     num_destroy: int,
     num_repair: int,
 ):
+    policy = LearningPolicy.EpsilonGreedy(0.15)
     with assert_raises(ValueError):
-        MABSelector(scores, num_destroy, num_repair, learning_policy)
+        MABSelector(scores, num_destroy, num_repair, policy)
 
 
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
 def test_call_with_only_one_operator_pair():
     # Only one operator pair, so the algorithm should select (0, 0).
     select = MABSelector(
@@ -80,6 +76,7 @@ def test_call_with_only_one_operator_pair():
         assert_equal(selected, (0, 0))
 
 
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
 def test_mab_epsilon_greedy():
     rng = rnd.default_rng()
 
@@ -98,7 +95,8 @@ def test_mab_epsilon_greedy():
         assert_equal(selected, (1, 0))
 
 
-@mark.parametrize("alpha", [0.25, 0.5])
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
+@pytest.mark.parametrize("alpha", [0.25, 0.5])
 def test_mab_ucb1(alpha):
     rng = rnd.default_rng()
     select = MABSelector([2, 1, 1, 0], 2, 1, LearningPolicy.UCB1(alpha))
@@ -112,6 +110,7 @@ def test_mab_ucb1(alpha):
     assert_equal(mab_select, (0, 0))
 
 
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
 def test_contextual_mab_requires_context():
     select = MABSelector(
         [2, 1, 1, 0],
@@ -124,6 +123,7 @@ def test_contextual_mab_requires_context():
         select.update(Zero(), 0, 0, outcome=Outcome.BEST)
 
 
+@pytest.mark.skipif(not MABWISER_AVAILABLE, reason="MABWiser not available")
 def text_contextual_mab_uses_context():
     rng = rnd.default_rng()
     select = MABSelector(