diff --git a/scipy_doctest/impl.py b/scipy_doctest/impl.py index b82d85c..e71e6bb 100644 --- a/scipy_doctest/impl.py +++ b/scipy_doctest/impl.py @@ -79,7 +79,7 @@ class DTConfig: Default is False. pytest_extra_ignore : list A list of names/modules to ignore when run under pytest plugin. This is - equivalent to using `--ignore=...` cmdline switch. + equivalent to using ``--ignore=...`` cmdline switch. pytest_extra_skip : dict Names/modules to skip when run under pytest plugin. This is equivalent to decorating the doctest with `@pytest.mark.skip` or adding @@ -92,6 +92,12 @@ class DTConfig: adding `# may vary` to the outputs of all examples. Each key is a doctest name to skip, and the corresponding value is a string. If not empty, the string value is used as the skip reason. + pytest_extra_requires : dict + Paths or functions to conditionally ignore unless requirements are met. + The format is ``{path/or/glob/pattern: requirement(s), full.func.name: requirement(s)}``, + where the values are PEP 508 dependency specifiers. If a requirement is not met, + the behavior is equivalent to using the ``--ignore=...`` command line switch for + paths, and to using a `pytest_extra_skip` for function names. CheckerKlass : object, optional The class for the Checker object. Must mimic the ``DTChecker`` API: subclass the `doctest.OutputChecker` and make the constructor signature @@ -125,6 +131,7 @@ def __init__(self, *, # DTChecker configuration pytest_extra_ignore=None, pytest_extra_skip=None, pytest_extra_xfail=None, + pytest_extra_requires=None, ): ### DTChecker configuration ### self.CheckerKlass = CheckerKlass or DTChecker @@ -217,6 +224,7 @@ def __init__(self, *, # DTChecker configuration self.pytest_extra_ignore = pytest_extra_ignore or [] self.pytest_extra_skip = pytest_extra_skip or {} self.pytest_extra_xfail = pytest_extra_xfail or {} + self.pytest_extra_requires = pytest_extra_requires or {} def try_convert_namedtuple(got): diff --git a/scipy_doctest/plugin.py b/scipy_doctest/plugin.py index 3d60bd7..aa18b9d 100644 --- a/scipy_doctest/plugin.py +++ b/scipy_doctest/plugin.py @@ -13,7 +13,7 @@ from .impl import DTParser, DebugDTRunner from .conftest import dt_config -from .util import np_errstate, matplotlib_make_nongui, temp_cwd +from .util import np_errstate, matplotlib_make_nongui, temp_cwd, is_req_satisfied from .frontend import find_doctests @@ -82,10 +82,16 @@ def pytest_ignore_collect(collection_path, config): if "tests" in path_str or "test_" in path_str: return True + fnmatch_ex = _pytest.pathlib.fnmatch_ex + for entry in config.dt_config.pytest_extra_ignore: - if entry in str(collection_path): + if fnmatch_ex(entry, collection_path): return True + for entry, reqs in config.dt_config.pytest_extra_requires.items(): + if fnmatch_ex(entry, collection_path): + return not is_req_satisfied(reqs) + def is_private(item): """Decide if an DocTestItem `item` is private. @@ -110,21 +116,26 @@ def _maybe_add_markers(item, config): dt_config = config.dt_config extra_skip = dt_config.pytest_extra_skip - skip_it = item.name in extra_skip - if skip_it: + if skip_it := item.name in extra_skip: reason = extra_skip[item.name] or '' item.add_marker( pytest.mark.skip(reason=reason) ) extra_xfail = dt_config.pytest_extra_xfail - fail_it = item.name in extra_xfail - if fail_it: + if fail_it := item.name in extra_xfail: reason = extra_xfail[item.name] or '' item.add_marker( pytest.mark.xfail(reason=reason) ) + extra_requires = dt_config.pytest_extra_requires + if req_str := extra_requires.get(item.name, None): + if not is_req_satisfied(req_str): + item.add_marker( + pytest.mark.skip(reason=f"requires {req_str}") + ) + def pytest_collection_modifyitems(config, items): """ diff --git a/scipy_doctest/util.py b/scipy_doctest/util.py index 4c6679d..8baf064 100644 --- a/scipy_doctest/util.py +++ b/scipy_doctest/util.py @@ -10,6 +10,10 @@ import inspect from contextlib import contextmanager +from typing import Sequence + +from importlib.metadata import version as get_version, PackageNotFoundError +from packaging.requirements import Requirement @contextmanager def matplotlib_make_nongui(): @@ -255,6 +259,20 @@ def get_public_objects(module, skiplist=None): return (items, names), failures +def is_req_satisfied(req_strs: str | Sequence[str]) -> bool: + """ Check if all PEP 508-compliant requirement(s) are satisfied or not. + """ + req_strs = [req_strs] if isinstance(req_strs, str) else req_strs + reqs = [Requirement(req_str) for req_str in req_strs] + if any(req.marker is not None for req in reqs): + msg = r"Markers not supported in `pytest_extra_requires`" + raise NotImplementedError(msg) + try: + return all(get_version(req.name) in req.specifier for req in reqs) + except PackageNotFoundError: + return False + + # XXX: not used ATM modules = [] def generate_log(module, test):