Source code for pytest_missing_modules.plugin
"""Pytest plugin implementation."""
from __future__ import annotations
import builtins
import importlib
import importlib.util
import sys
from contextlib import contextmanager
from functools import wraps
from threading import Lock
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from typing import (
Callable,
)
if sys.version_info >= (3, 10):
from typing import Concatenate, ParamSpec, TypeVar
else:
from typing_extension import Concatenate, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
_LOCK = Lock()
"""Lock used to make sure that :func:`missing_modules` is compatible
with :mod:`pytest-xdist`."""
[docs]
class MissingModulesContextGenerator:
"""Context manager generator that raises :py:class:`ImportError` for specified modules.
In the provided context, an import of any modules in that list
will raise an :py:class:`ImportError`.
Args:
monkeypatch: The monkeypatch object used to perform
all patches.
""" # noqa: E501
def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: # noqa: D107
self.monkeypatch = monkeypatch
[docs]
@contextmanager
def __call__( # noqa: C901
self,
*names: str,
error_msg: str = "Mocked import error for '{name}'",
patch_import: bool = True,
patch_find_spec: bool = True,
) -> Iterator[pytest.MonkeyPatch]:
"""Enter the context manager.
Args:
names: A list of modules names.
error_msg: A string template for import errors.
patch_import: Whether to patch :func:`import<__import__>` and
:func:`importlib.import_module`.
patch_find_spec: Whether to patch
:func:`importlib.util.find_spec`.
Yields:
A monkeypatch instance that mocks imports of the specified
modules.
"""
real_import = builtins.__import__
real_import_module = importlib.import_module
real_find_spec = importlib.util.find_spec
def should_mock(name: str) -> bool:
return name.partition(".")[0] in names
def mock_import_func(
import_func: Callable[Concatenate[str, P], R],
) -> Callable[Concatenate[str, P], R]:
@wraps(import_func)
def wrapper(name: str, *args: P.args, **kwargs: P.kwargs) -> R:
if should_mock(name):
msg = error_msg.format(name=name)
raise ImportError(msg)
return import_func(name, *args, **kwargs)
return wrapper
def mock_find_spec_func(
find_spec_func: Callable[Concatenate[str, P], R],
) -> Callable[Concatenate[str, P], R | None]:
@wraps(find_spec_func)
def wrapper(name: str, *args: P.args, **kwargs: P.kwargs) -> R | None:
if should_mock(name):
return None
return find_spec_func(name, *args, **kwargs)
return wrapper
with self.monkeypatch.context() as m, _LOCK:
module_names = tuple(sys.modules.keys())
for module_name in module_names:
if should_mock(module_name):
m.delitem(sys.modules, module_name)
if patch_import:
m.setattr(builtins, "__import__", mock_import_func(real_import))
m.setattr(
importlib,
"import_module",
mock_import_func(real_import_module),
)
if patch_find_spec:
m.setattr(
importlib.util,
"find_spec",
mock_find_spec_func(real_find_spec),
)
yield m
[docs]
@pytest.fixture
def missing_modules(monkeypatch: pytest.MonkeyPatch) -> MissingModulesContextGenerator:
"""Pytest fixture that can be used to create missing_modules contexts.
Args:
monkeypatch: A monkeypatch fixture, provided by :mod:`pytest`.
Returns:
A context manager that can be used to create missing modules contexts.
Examples:
This first examples shows the most basic usage of this module.
.. code-block:: python
:caption: The following must be placed in a test file.
import pytest
def test_missing_numpy(missing_modules):
with missing_modules("numpy"):
with pytest.raises(ImportError):
# Will always raise an error, even if NumPy is installed
import numpy
A more interesting example would be to check that your package can still
be imported, even if a dependency is missing.
.. code-block:: python
:caption: The following must be placed in a test file.
import importlib
import importlib.util
import pytest
import my_package # This succeeds
def test_missing_dependency(missing_modules):
with missing_modules("plotly", patch_find_spec=False):
# We check that Plotly is installed
assert importlib.util.find_spec("plotly") is not None
# .. but not importable
with pytest.raises(ImportError):
import plotly
# We check our package can still be imported
importlib.reload(my_package)
"""
return MissingModulesContextGenerator(monkeypatch)