Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Denivy Braiam Rück
Deysha Rivera
Dheeraj C K
Dhiren Serai
Dhruv Sharma
Diego Russo
Dima Gerasimov
Dmitry Dygalo
Expand Down
5 changes: 5 additions & 0 deletions changelog/15267.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 11 additions & 6 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions src/_pytest/deferred_imports.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 22 additions & 1 deletion src/_pytest/outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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

Expand All @@ -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]
Expand All @@ -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
16 changes: 10 additions & 6 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions src/_pytest/skipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions testing/test_deferred_importorskip.py
Original file line number Diff line number Diff line change
@@ -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"])
Loading