diff --git a/AUTHORS b/AUTHORS index 136ffd6c5ee..8f1aceabfa6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -146,6 +146,7 @@ Denivy Braiam Rück Deysha Rivera Dheeraj C K Dhiren Serai +Dhruv Sharma Diego Russo Dima Gerasimov Dmitry Dygalo diff --git a/changelog/15267.feature.rst b/changelog/15267.feature.rst new file mode 100644 index 00000000000..5af9554279b --- /dev/null +++ b/changelog/15267.feature.rst @@ -0,0 +1,5 @@ +Add an opt-in ``defer=True`` parameter to :func:`pytest.importorskip`. +During test module or conftest import, unavailable dependencies can now gate +individual tests at setup while allowing collection, parametrization, and +``--collect-only`` to include those tests. Eager dependency use or a subsequent +import error falls back to the existing module-level skip. diff --git a/src/_pytest/config/__init__.py b/src/_pytest/config/__init__.py index d1972228756..61eddd11a45 100644 --- a/src/_pytest/config/__init__.py +++ b/src/_pytest/config/__init__.py @@ -784,13 +784,18 @@ def _importconftest( except KeyError: pass + from _pytest.deferred_imports import collect_deferred_imports + try: - mod = import_path( - conftestpath, - mode=importmode, - root=rootpath, - consider_namespace_packages=consider_namespace_packages, - ) + with collect_deferred_imports( + cast(Config, self.get_plugin("pytestconfig")), conftestpath + ): + mod = import_path( + conftestpath, + mode=importmode, + root=rootpath, + consider_namespace_packages=consider_namespace_packages, + ) except Exception as e: assert e.__traceback__ is not None raise ConftestImportFailure(conftestpath, cause=e) from e diff --git a/src/_pytest/deferred_imports.py b/src/_pytest/deferred_imports.py new file mode 100644 index 00000000000..2ca2752df22 --- /dev/null +++ b/src/_pytest/deferred_imports.py @@ -0,0 +1,91 @@ +"""Opt-in dependency gates recorded while importing test modules or conftests.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from _pytest.outcomes import Skipped +from _pytest.stash import StashKey + + +if TYPE_CHECKING: + from _pytest.config import Config + from _pytest.nodes import Item + + +@dataclass +class DeferredGate: + skipped: Skipped + filename: str + lineno: int + + +@dataclass +class ImportContext: + gates: list[DeferredGate] + + +_context: ContextVar[ImportContext | None] = ContextVar( + "pytest_deferred_import", default=None +) +_gates_key = StashKey[dict[Path, list[DeferredGate]]]() +active_gate_key = StashKey[DeferredGate]() + + +class UnavailableModule: + def __init__(self, skipped: Skipped) -> None: + self._skipped = skipped + + def __getattr__(self, name: str) -> object: + # Collection introspects objects for fixtures, marks, and test classes. + if name.startswith("_"): + raise AttributeError(name) + raise self._skipped + + +def defer_skip(skipped: Skipped) -> UnavailableModule: + __tracebackhide__ = True + context = _context.get() + if context is None: + raise skipped + caller = sys._getframe(2) + context.gates.append( + DeferredGate(skipped, caller.f_code.co_filename, caller.f_lineno) + ) + return UnavailableModule(skipped) + + +@contextmanager +def collect_deferred_imports(config: Config, path: Path) -> Iterator[None]: + context = ImportContext([]) + token = _context.set(context) + try: + try: + yield + except (Exception, Skipped): + # If eager use or a transitive import prevents collection, retain + # the original module-level skip rather than report a new error. + if context.gates: + raise context.gates[0].skipped from None + raise + else: + if context.gates: + config.stash.setdefault(_gates_key, {})[path] = context.gates + finally: + _context.reset(token) + + +def skip_if_gated(item: Item) -> None: + for path, gates in item.config.stash.get(_gates_key, {}).items(): + if path == item.path or ( + path.name == "conftest.py" and item.path.is_relative_to(path.parent) + ): + gate = gates[0] + item.stash[active_gate_key] = gate + raise Skipped(gate.skipped.msg) diff --git a/src/_pytest/outcomes.py b/src/_pytest/outcomes.py index 257b95cf585..b18ee2f6863 100644 --- a/src/_pytest/outcomes.py +++ b/src/_pytest/outcomes.py @@ -201,6 +201,7 @@ def importorskip( reason: str | None = None, *, exc_type: type[ImportError] | None = None, + defer: bool = False, ) -> Any: """Import and return the requested module ``modname``, or skip the current test if the module cannot be imported. @@ -224,6 +225,13 @@ def importorskip( See :ref:`import-or-skip-import-error` for details. + :param defer: + If true during import of a test module or conftest, collect tests even + when the dependency is unavailable and skip each at setup instead. + The remaining module body runs. Eager use of the unavailable module, + or another import failure, falls back to skipping the whole module. + Inside a test or fixture this has the usual immediate-skip behavior. + :returns: The imported module. This should be assigned to its canonical name. @@ -242,6 +250,10 @@ def importorskip( .. versionchanged:: 9.1 The default for ``exc_type`` is now :class:`ModuleNotFoundError`. + + .. versionadded:: 9.2 + + The ``defer`` parameter. """ import warnings @@ -268,6 +280,10 @@ def importorskip( reason = f"could not import {modname!r}: {exc}" skipped = Skipped(reason, allow_module_level=True) if skipped: + if defer: + from _pytest.deferred_imports import defer_skip + + return defer_skip(skipped) raise skipped mod = sys.modules[modname] @@ -279,8 +295,13 @@ def importorskip( from packaging.version import Version if verattr is None or Version(verattr) < Version(minversion): - raise Skipped( + skipped = Skipped( f"module {modname!r} has __version__ {verattr!r}, required is: {minversion!r}", allow_module_level=True, ) + if defer: + from _pytest.deferred_imports import defer_skip + + return defer_skip(skipped) + raise skipped return mod diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 1ce7f71a7bd..df3a8b733d7 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -55,6 +55,7 @@ from _pytest.config import hookimpl from _pytest.config import UsageError from _pytest.config.argparsing import Parser +from _pytest.deferred_imports import collect_deferred_imports from _pytest.deprecated import CALLSPEC2_RENAMED from _pytest.deprecated import check_ispytest from _pytest.fixtures import _resolve_args_directness @@ -525,12 +526,15 @@ def importtestmodule( # We assume we are only called once per module. importmode = config.getoption("--import-mode") try: - mod = import_path( - path, - mode=importmode, - root=config.rootpath, - consider_namespace_packages=config.getini("consider_namespace_packages"), - ) + with collect_deferred_imports(config, path): + mod = import_path( + path, + mode=importmode, + root=config.rootpath, + consider_namespace_packages=config.getini( + "consider_namespace_packages" + ), + ) except SyntaxError as e: raise nodes.Collector.CollectError( ExceptionInfo.from_current().getrepr(style="short") diff --git a/src/_pytest/skipping.py b/src/_pytest/skipping.py index aa97e3b7dc6..3189f833d30 100644 --- a/src/_pytest/skipping.py +++ b/src/_pytest/skipping.py @@ -14,6 +14,8 @@ from _pytest.config import Config from _pytest.config import hookimpl from _pytest.config.argparsing import Parser +from _pytest.deferred_imports import active_gate_key +from _pytest.deferred_imports import skip_if_gated from _pytest.mark.structures import Mark from _pytest.nodes import Item from _pytest.outcomes import fail @@ -246,6 +248,7 @@ def evaluate_xfail_marks(item: Item) -> Xfail | None: @hookimpl(tryfirst=True) def pytest_runtest_setup(item: Item) -> None: + skip_if_gated(item) skipped = evaluate_skip_marks(item) if skipped: raise skip.Exception(skipped.reason, _use_item_location=True) @@ -278,6 +281,9 @@ def pytest_runtest_makereport( item: Item, call: CallInfo[None] ) -> Generator[None, TestReport, TestReport]: rep = yield + gate = item.stash.get(active_gate_key, None) + if rep.skipped and call.when == "setup" and gate is not None: + rep.longrepr = (gate.filename, gate.lineno, "Skipped: " + str(gate.skipped)) xfailed = item.stash.get(xfailed_key, None) if item.config.option.runxfail: pass # don't interfere diff --git a/testing/test_deferred_importorskip.py b/testing/test_deferred_importorskip.py new file mode 100644 index 00000000000..4c5a36d9517 --- /dev/null +++ b/testing/test_deferred_importorskip.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from _pytest.pytester import Pytester +import pytest + + +def test_deferred_collects_all_tests(pytester: Pytester) -> None: + pytester.makepyfile(""" + import pytest + missing = pytest.importorskip("missing_deferred_dependency", defer=True) + @pytest.mark.parametrize("value", [1, 2]) + def test_dependent(value): + missing.work(value) + """) + result = pytester.runpytest("--collect-only", "-q") + result.stdout.fnmatch_lines( + ["*test_dependent[[]1[]]", "*test_dependent[[]2[]]", "*2 tests collected*"] + ) + result = pytester.runpytest("-rs") + result.assert_outcomes(skipped=2) + result.stdout.fnmatch_lines(["*missing_deferred_dependency*"]) + + +def test_deferred_conftest_scopes_to_directory(pytester: Pytester) -> None: + pytester.makepyfile( + **{ + "gated/conftest.py": """ + import pytest + missing = pytest.importorskip("missing_deferred_dependency", defer=True) + @pytest.fixture + def dependency(): + return missing.value + """, + "gated/test_a.py": "def test_gated(dependency): pass", + "test_b.py": "def test_unrelated(): pass", + } + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1, skipped=1) + + +@pytest.mark.parametrize( + "tail", ["value = missing.value", "import another_missing_dependency"] +) +def test_deferred_eager_use_falls_back_to_module_skip( + pytester: Pytester, tail: str +) -> None: + pytester.makepyfile( + "import pytest\n" + 'missing = pytest.importorskip("missing_deferred_dependency", defer=True)\n' + + tail + + "\ndef test_example(): pass" + ) + result = pytester.runpytest() + result.assert_outcomes(skipped=1) + + +def test_deferred_available_returns_real_module(pytester: Pytester) -> None: + pytester.makepyfile(""" + import pytest + import sys + actual = pytest.importorskip("sys", defer=True) + def test_available(): + assert actual is sys + """) + pytester.runpytest().assert_outcomes(passed=1) + + +def test_deferred_inside_test_skips_immediately(pytester: Pytester) -> None: + pytester.makepyfile(""" + import pytest + def test_missing(): + pytest.importorskip("missing_deferred_dependency", defer=True) + pytest.fail("must not continue") + """) + result = pytester.runpytest("-rs") + result.assert_outcomes(skipped=1) + result.stdout.fnmatch_lines( + ["SKIPPED [[]1[]] test_deferred_inside_test_skips_immediately.py:3: *"] + ) + + +def test_unrelated_import_errors_still_reported(pytester: Pytester) -> None: + pytester.makepyfile("import another_missing_dependency\ndef test_example(): pass") + pytester.runpytest().assert_outcomes(errors=1) + + +def test_deferred_minversion_and_original_location(pytester: Pytester) -> None: + pytester.makepyfile(dependency='__version__ = "1.0"') + pytester.makepyfile( + test_version=""" + import pytest + dependency = pytest.importorskip("dependency", minversion="2.0", defer=True) + def test_first(): pass + def test_second(): pass + """ + ) + result = pytester.runpytest("-rs") + result.assert_outcomes(skipped=2) + result.stdout.fnmatch_lines( + ["SKIPPED [[]2[]] test_version.py:2: *required is: '2.0'"] + ) + + +def test_deferred_preserves_existing_markers(pytester: Pytester) -> None: + pytester.makepyfile(""" + import pytest + pytestmark = pytest.mark.slow + pytest.importorskip("missing_deferred_dependency", defer=True) + def test_marked(): pass + """) + result = pytester.runpytest("-m", "not slow", "-o", "markers=slow: slow tests") + result.assert_outcomes(deselected=1) + + +def test_deferred_custom_reason(pytester: Pytester) -> None: + pytester.makepyfile(""" + import pytest + pytest.importorskip("missing_deferred_dependency", defer=True, reason="needs extra") + def test_first(): pass + def test_second(): pass + """) + result = pytester.runpytest("-rs") + result.assert_outcomes(skipped=2) + result.stdout.fnmatch_lines(["SKIPPED [[]2[]] *: needs extra"])