Repository: scipy/scipy_doctest (language: python)

Issue:
Make sure optional dependencies are, in fact, optional
Add a CI job with no scipy and no matplotlib, make sure self-tests finish (pytest.skip tests which require optionals) .

While at it, may expand the CI matrix to include a range of python versions (match scipy), also OSes. Since this all is pure python, platform-dependent problems are unlikely --- but there's always room for surprises.


Discussion / hints:
@Sheila-nk would be great if you could take a look. I think it's easiest to replicate the existing workflow with a single python & pytest version, remove pip installs and see what fails.

Fix patch:
diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml
index 0da483c..2c4d66e 100644
--- a/.github/workflows/pip.yml
+++ b/.github/workflows/pip.yml
@@ -18,37 +18,44 @@ jobs:
         python-version: ['3.10', '3.11']
         os:  [ubuntu-latest]
         pytest: ['"pytest<8.0"', pytest]
-        # pip cache paths
-        include:
-        - os: ubuntu-latest
-          path: ~/.cache/pip
 
     steps:
-    - uses: actions/checkout@v2
+    # actions/setup-python@v5 has built-in functionality for caching and restoring dependencies. 
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
+        cache: 'pip' # caching pip dependencies
 
-    # use pip caching
-    - uses: actions/cache@v2
-      with:
-        path: ${{ matrix.path }}
-        key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
-        restore-keys: |
-         ${{ runner.os }}-pip-
-
-    # Install dependencies 
-    - name: Install
+    # Install numpy and pytest
+    - name: Install core dependencies
       run: |
-        python -m pip install numpy scipy matplotlib ${{matrix.pytest}}
+        python -m pip install --upgrade pip
+        python -m pip install ${{matrix.pytest}}
         python -m pip install -e .
 
-    - name: Self-test
+    # Run self-tests without Scipy and MPL
+    # Tests that require Scipy and MPL will be skipped
+    - name: Self-test without SciPy and MPL
+      run: |
+        pytest --pyargs scpdt -v
+
+    - name: Self-test CLI without SciPy and MPL
+      run: |
+        python -m scpdt scpdt/tests/finder_cases.py -vv
+
+    # Install Scipy and MPL
+    - name: Install optional dependencies
+      run: |
+        python -m pip install -e '.[test]'
+
+    # Tests that require Scipy and MPL can now run
+    - name: Self-test with SciPy and MPL
       run: |
-        pytest --pyargs scpdt -v -s
+        pytest --pyargs scpdt -v
 
-    - name: Self-test CLI
+    - name: Self-test CLI with SciPy and MPL
       run: |
         python -m scpdt scpdt/tests/finder_cases.py -vv
 
@@ -62,7 +69,7 @@ jobs:
 
     - name: Run testmod a scipy submodule -- Public API onlly
       run: |
-        python -mpip install pooch
+        python -m pip install pooch
         python -c'from scipy import ndimage; from scpdt import testmod; testmod(ndimage, verbose=True, strategy="api")'
 
     - name: Test pytest plugin
diff --git a/pyproject.toml b/pyproject.toml
index d5c9585..ae4721d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,8 +24,7 @@ dependencies  = [
 [project.optional-dependencies]
 test = [
     "scipy",
-    "matplotlib",
-    "pytest"
+    "matplotlib"
 ]
 
 [project.urls]


Test patch:
diff --git a/scpdt/tests/test_pytest_configuration.py b/scpdt/tests/test_pytest_configuration.py
index 797501d..c51a63a 100644
--- a/scpdt/tests/test_pytest_configuration.py
+++ b/scpdt/tests/test_pytest_configuration.py
@@ -1,8 +1,20 @@
 import pytest
+
+try:
+    import matplotlib.pyplot as plt    # noqa
+    HAVE_MATPLOTLIB = True
+except Exception:
+    HAVE_MATPLOTLIB = False
+
+try:
+    import scipy    # noqa
+    HAVE_SCIPY = True
+except Exception:
+    HAVE_SCIPY = False
+
 from pathlib import PosixPath, Path
 
 from . import module_cases, failure_cases, failure_cases_2, stopwords_cases, local_file_cases
-from scpdt.conftest import dt_config
 
 # XXX: this is a bit hacky and repetetive. Can rework?
 
@@ -10,10 +22,11 @@
 pytest_plugins = ['pytester']
 
 
+@pytest.mark.skipif(not HAVE_SCIPY, reason='need scipy')
 def test_module_cases(pytester):
     """Test that pytest uses the DTChecker for doctests."""
     path_str = module_cases.__file__
-    python_file = PosixPath(path_str)
+    python_file = Path(path_str)
     result = pytester.inline_run(python_file, "--doctest-modules")
     assert result.ret == pytest.ExitCode.OK
 
@@ -22,23 +35,25 @@ def test_failure_cases(pytester):
     file_list = [failure_cases, failure_cases_2]
     for file in file_list:
         path_str = file.__file__
-        python_file = PosixPath(path_str)
+        python_file = Path(path_str)
         result = pytester.inline_run(python_file, "--doctest-modules")
     assert result.ret == pytest.ExitCode.TESTS_FAILED
     
 
+@pytest.mark.skipif(not HAVE_MATPLOTLIB, reason='need matplotlib')
 def test_stopword_cases(pytester):
     """Test that pytest uses the DTParser for doctests."""
     path_str = stopwords_cases.__file__
-    python_file = PosixPath(path_str)
+    python_file = Path(path_str)
     result = pytester.inline_run(python_file, "--doctest-modules")
     assert result.ret == pytest.ExitCode.OK
 
 
+@pytest.mark.skipif(not HAVE_SCIPY, reason='need scipy')
 def test_local_file_cases(pytester):
     """Test that local files are found for use in doctests.
     """
     path_str = local_file_cases.__file__
-    python_file = PosixPath(path_str)
+    python_file = Path(path_str)
     result = pytester.inline_run(python_file, "--doctest-modules")
     assert result.ret == pytest.ExitCode.OK
diff --git a/scpdt/tests/test_skipmarkers.py b/scpdt/tests/test_skipmarkers.py
index d68e2b8..dcd6f5f 100644
--- a/scpdt/tests/test_skipmarkers.py
+++ b/scpdt/tests/test_skipmarkers.py
@@ -1,4 +1,11 @@
 import doctest
+
+try:
+    import matplotlib.pyplot as plt    # noqa
+    HAVE_MATPLOTLIB = True
+except Exception:
+    HAVE_MATPLOTLIB = False
+
 import pytest
 
 from ..impl import DTConfig, DTParser, DebugDTRunner
@@ -9,6 +16,7 @@ class TestSyntaxErrors:
 
     Either mark it with +SKIP or as pseudocode.
     """
+    @pytest.mark.skipif(not HAVE_MATPLOTLIB, reason='need matplotlib')
     def test_invalid_python(self):
         # This string raises
         # TypeError("unsupported operand type(s) for +: 'int' and 'tuple'")
@@ -100,6 +108,7 @@ class TestStopwords:
     """If an example contains a stopword, the example still needs to be a valid
     python code, but the output is not checked.
     """
+    @pytest.mark.skipif(not HAVE_MATPLOTLIB, reason='need matplotlib')
     def test_bogus_output(self):
         # 'plt.xlim(' is a stopword by default in the DTParser. This is because
         # it returns a tuple, which we don't want to litter our docstrings with.
diff --git a/scpdt/tests/test_testmod.py b/scpdt/tests/test_testmod.py
index c7f17e1..e461d59 100644
--- a/scpdt/tests/test_testmod.py
+++ b/scpdt/tests/test_testmod.py
@@ -1,8 +1,16 @@
 import io
 import doctest
+
 from contextlib import redirect_stderr
 
 import numpy as np
+
+try:
+    import matplotlib.pyplot as plt
+    HAVE_MATPLOTLIB = True
+except Exception:
+    HAVE_MATPLOTLIB = False
+
 import pytest
 
 try:
@@ -24,12 +32,14 @@
 _VERBOSE = 2
 
 
+@pytest.mark.skipif(not HAVE_SCIPY, reason='need scipy')
 def test_module():
     res, _ = _testmod(module, verbose=_VERBOSE)
     assert res.failed == 0
     assert res.attempted != 0
 
 
+@pytest.mark.skipif(not HAVE_SCIPY, reason='need scipy')
 def test_module_vanilla_dtfinder():
     config = DTConfig()
     config.stopwords = []
@@ -38,12 +48,14 @@ def test_module_vanilla_dtfinder():
     assert res.attempted != 0
 
 
+@pytest.mark.skipif(not HAVE_MATPLOTLIB, reason='need matplotlib')
 def test_stopwords():
     res, _ = _testmod(stopwords, verbose=_VERBOSE)
     assert res.failed == 0
     assert res.attempted != 0
+    
 
-
+@pytest.mark.skipif(not HAVE_SCIPY, reason='need scipy')
 def test_public_obj_discovery():
     res, _ = _testmod(module, verbose=_VERBOSE, strategy='api')
     assert res.failed == 0