Skip to content

Commit 01e8250

Browse files
authored
Merge pull request #204 from scipy/optional_requirements
Add `pytest_extra_requires` to conditionally ignore paths / skip functions unless requirements are met
2 parents bc35697 + d4daabd commit 01e8250

File tree

3 files changed

+44
-7
lines changed

3 files changed

+44
-7
lines changed

scipy_doctest/impl.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class DTConfig:
7979
Default is False.
8080
pytest_extra_ignore : list
8181
A list of names/modules to ignore when run under pytest plugin. This is
82-
equivalent to using `--ignore=...` cmdline switch.
82+
equivalent to using ``--ignore=...`` cmdline switch.
8383
pytest_extra_skip : dict
8484
Names/modules to skip when run under pytest plugin. This is
8585
equivalent to decorating the doctest with `@pytest.mark.skip` or adding
@@ -92,6 +92,12 @@ class DTConfig:
9292
adding `# may vary` to the outputs of all examples.
9393
Each key is a doctest name to skip, and the corresponding value is
9494
a string. If not empty, the string value is used as the skip reason.
95+
pytest_extra_requires : dict
96+
Paths or functions to conditionally ignore unless requirements are met.
97+
The format is ``{path/or/glob/pattern: requirement(s), full.func.name: requirement(s)}``,
98+
where the values are PEP 508 dependency specifiers. If a requirement is not met,
99+
the behavior is equivalent to using the ``--ignore=...`` command line switch for
100+
paths, and to using a `pytest_extra_skip` for function names.
95101
CheckerKlass : object, optional
96102
The class for the Checker object. Must mimic the ``DTChecker`` API:
97103
subclass the `doctest.OutputChecker` and make the constructor signature
@@ -125,6 +131,7 @@ def __init__(self, *, # DTChecker configuration
125131
pytest_extra_ignore=None,
126132
pytest_extra_skip=None,
127133
pytest_extra_xfail=None,
134+
pytest_extra_requires=None,
128135
):
129136
### DTChecker configuration ###
130137
self.CheckerKlass = CheckerKlass or DTChecker
@@ -217,6 +224,7 @@ def __init__(self, *, # DTChecker configuration
217224
self.pytest_extra_ignore = pytest_extra_ignore or []
218225
self.pytest_extra_skip = pytest_extra_skip or {}
219226
self.pytest_extra_xfail = pytest_extra_xfail or {}
227+
self.pytest_extra_requires = pytest_extra_requires or {}
220228

221229

222230
def try_convert_namedtuple(got):

scipy_doctest/plugin.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from .impl import DTParser, DebugDTRunner
1515
from .conftest import dt_config
16-
from .util import np_errstate, matplotlib_make_nongui, temp_cwd
16+
from .util import np_errstate, matplotlib_make_nongui, temp_cwd, is_req_satisfied
1717
from .frontend import find_doctests
1818

1919

@@ -82,10 +82,16 @@ def pytest_ignore_collect(collection_path, config):
8282
if "tests" in path_str or "test_" in path_str:
8383
return True
8484

85+
fnmatch_ex = _pytest.pathlib.fnmatch_ex
86+
8587
for entry in config.dt_config.pytest_extra_ignore:
86-
if entry in str(collection_path):
88+
if fnmatch_ex(entry, collection_path):
8789
return True
8890

91+
for entry, reqs in config.dt_config.pytest_extra_requires.items():
92+
if fnmatch_ex(entry, collection_path):
93+
return not is_req_satisfied(reqs)
94+
8995

9096
def is_private(item):
9197
"""Decide if an DocTestItem `item` is private.
@@ -110,21 +116,26 @@ def _maybe_add_markers(item, config):
110116
dt_config = config.dt_config
111117

112118
extra_skip = dt_config.pytest_extra_skip
113-
skip_it = item.name in extra_skip
114-
if skip_it:
119+
if skip_it := item.name in extra_skip:
115120
reason = extra_skip[item.name] or ''
116121
item.add_marker(
117122
pytest.mark.skip(reason=reason)
118123
)
119124

120125
extra_xfail = dt_config.pytest_extra_xfail
121-
fail_it = item.name in extra_xfail
122-
if fail_it:
126+
if fail_it := item.name in extra_xfail:
123127
reason = extra_xfail[item.name] or ''
124128
item.add_marker(
125129
pytest.mark.xfail(reason=reason)
126130
)
127131

132+
extra_requires = dt_config.pytest_extra_requires
133+
if req_str := extra_requires.get(item.name, None):
134+
if not is_req_satisfied(req_str):
135+
item.add_marker(
136+
pytest.mark.skip(reason=f"requires {req_str}")
137+
)
138+
128139

129140
def pytest_collection_modifyitems(config, items):
130141
"""

scipy_doctest/util.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
import inspect
1111
from contextlib import contextmanager
1212

13+
from typing import Sequence
14+
15+
from importlib.metadata import version as get_version, PackageNotFoundError
16+
from packaging.requirements import Requirement
1317

1418
@contextmanager
1519
def matplotlib_make_nongui():
@@ -255,6 +259,20 @@ def get_public_objects(module, skiplist=None):
255259
return (items, names), failures
256260

257261

262+
def is_req_satisfied(req_strs: str | Sequence[str]) -> bool:
263+
""" Check if all PEP 508-compliant requirement(s) are satisfied or not.
264+
"""
265+
req_strs = [req_strs] if isinstance(req_strs, str) else req_strs
266+
reqs = [Requirement(req_str) for req_str in req_strs]
267+
if any(req.marker is not None for req in reqs):
268+
msg = r"Markers not supported in `pytest_extra_requires`"
269+
raise NotImplementedError(msg)
270+
try:
271+
return all(get_version(req.name) in req.specifier for req in reqs)
272+
except PackageNotFoundError:
273+
return False
274+
275+
258276
# XXX: not used ATM
259277
modules = []
260278
def generate_log(module, test):

0 commit comments

Comments
 (0)