Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changed importError to ModuleNotFoundError #12220

Merged
merged 14 commits into from
Apr 26, 2024
Merged
5 changes: 5 additions & 0 deletions changelog/11523.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:func:`pytest.importorskip` will now issue a warning if the module could be found, but raised :class:`ImportError` instead of :class:`ModuleNotFoundError`.

The warning can be suppressed by passing ``exc_type=ImportError`` to :func:`pytest.importorskip`.

See :ref:`import-or-skip-import-error` for details.
35 changes: 35 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,41 @@ Below is a complete list of all pytest features which are considered deprecated.
:class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.


.. _import-or-skip-import-error:

``pytest.importorskip`` default behavior regarding :class:`ImportError`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. deprecated:: 8.2

Traditionally :func:`pytest.importorskip` will capture :class:`ImportError`, with the original intent being to skip
tests where a dependent module is not installed, for example testing with different dependencies.

However some packages might be installed in the system, but are not importable due to
some other issue, for example, a compilation error or a broken installation. In those cases :func:`pytest.importorskip`
would still silently skip the test, but more often than not users would like to see the unexpected
error so the underlying issue can be fixed.

In ``8.2`` the ``exc_type`` parameter has been added, giving users the ability of passing :class:`ModuleNotFoundError`
to skip tests only if the module cannot really be found, and not because of some other error.

Catching only :class:`ModuleNotFoundError` by default (and let other errors propagate) would be the best solution,
nicoddemus marked this conversation as resolved.
Show resolved Hide resolved
however for backward compatibility, pytest will keep the existing behavior but raise an warning if:

1. The captured exception is of type :class:`ImportError`, and:
2. The user does not pass ``exc_type`` explicitly.

If the import attempt raises :class:`ModuleNotFoundError` (the usual case), then the module is skipped and no
warning is emitted.

This way, the usual cases will keep working the same way, while unexpected errors will now issue a warning, with
users being able to supress the warning by passing ``exc_type=ImportError`` explicitly.

In ``9.0``, the warning will turn into an error, and in ``9.1`` :func:`pytest.importorskip` will only capture
:class:`ModuleNotFoundError` by default and no warnings will be issued anymore -- but users can still capture
:class:`ImportError` by passing it to ``exc_type``.


.. _node-ctor-fspath-deprecation:

``fspath`` argument for Node constructors replaced with ``pathlib.Path``
Expand Down
63 changes: 60 additions & 3 deletions src/_pytest/outcomes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from typing import Type
from typing import TypeVar

from .warning_types import PytestDeprecationWarning


class OutcomeException(BaseException):
"""OutcomeException and its subclass instances indicate and contain info
Expand Down Expand Up @@ -192,7 +194,11 @@


def importorskip(
modname: str, minversion: Optional[str] = None, reason: Optional[str] = None
modname: str,
minversion: Optional[str] = None,
reason: Optional[str] = None,
*,
exc_type: Optional[Type[ImportError]] = None,
) -> Any:
"""Import and return the requested module ``modname``, or skip the
current test if the module cannot be imported.
Expand All @@ -205,30 +211,81 @@
:param reason:
If given, this reason is shown as the message when the module cannot
be imported.
:param exc_type:
The exception that should be captured in order to skip modules.
Must be :py:class:`ImportError` or a subclass.

If the module can be imported but raises :class:`ImportError`, pytest will
issue a warning to the user, as often users expect the module not to be
found (which would raise :class:`ModuleNotFoundError` instead).

This warning can be suppressed by passing ``exc_type=ImportError`` explicitly.

See :ref:`import-or-skip-import-error` for details.


:returns:
The imported module. This should be assigned to its canonical name.

Example::

docutils = pytest.importorskip("docutils")

.. versionadded:: 8.2

The ``exc_type`` parameter.
"""
import warnings

__tracebackhide__ = True
compile(modname, "", "eval") # to catch syntaxerrors

# Until pytest 9.1, we will warn the user if we catch ImportError (instead of ModuleNotFoundError),
# as this might be hiding an installation/environment problem, which is not usually what is intended
# when using importorskip() (#11523).
# In 9.1, to keep the function signature compatible, we just change the code below to:
# 1. Use `exc_type = ModuleNotFoundError` if `exc_type` is not given.
# 2. Remove `warn_on_import` and the warning handling.
if exc_type is None:
exc_type = ImportError
warn_on_import_error = True

Check warning on line 251 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L250-L251

Added lines #L250 - L251 were not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused by the Codecov annotations here. It looks to me like this (and the others) should be covered by tests just fine? Are the added tests not running or something? Or is codecov broken? Or am I just missing something?

Copy link
Member

@nicoddemus nicoddemus Apr 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, but this is definitely being covered by tests (and normal tests, not through pytester either). I even tested this locally by adding a hard assert 0 in this line to ensure it is hit by the tests, and it is (as expected).

Not sure what's going on.

else:
warn_on_import_error = False

Check warning on line 253 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L253

Added line #L253 was not covered by tests

skipped: Optional[Skipped] = None
warning: Optional[Warning] = None

Check warning on line 256 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L255-L256

Added lines #L255 - L256 were not covered by tests

with warnings.catch_warnings():
# Make sure to ignore ImportWarnings that might happen because
# of existing directories with the same name we're trying to
# import but without a __init__.py file.
warnings.simplefilter("ignore")

try:
__import__(modname)
except ImportError as exc:
except exc_type as exc:

Check warning on line 266 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L266

Added line #L266 was not covered by tests
# Do not raise or issue warnings inside the catch_warnings() block.
if reason is None:
reason = f"could not import {modname!r}: {exc}"
raise Skipped(reason, allow_module_level=True) from None
skipped = Skipped(reason, allow_module_level=True)

Check warning on line 270 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L270

Added line #L270 was not covered by tests

if warn_on_import_error and type(exc) is ImportError:
nicoddemus marked this conversation as resolved.
Show resolved Hide resolved
lines = [

Check warning on line 273 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L273

Added line #L273 was not covered by tests
"",
f"Module '{modname}' was found, but when imported by pytest it raised:",
f" {exc!r}",
"In pytest 9.1 this warning will become an error by default.",
"You can fix the underlying problem, or alternatively overwrite this behavior and silence this "
"warning by passing exc_type=ImportError explicitly.",
"See https://docs.pytest.org/en/stable/deprecations.html#pytest-importorskip-default-behavior-regarding-importerror",
]
warning = PytestDeprecationWarning("\n".join(lines))

Check warning on line 282 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L282

Added line #L282 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not call warning.warn(...) here directly, given that warning = ... is only set from here?

Copy link
Member

@nicoddemus nicoddemus Apr 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because at this point we are inside the with warnings.catch_warnings(): block, which would catch our own warning.


if warning:
warnings.warn(warning, stacklevel=2)

Check warning on line 285 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L285

Added line #L285 was not covered by tests
if skipped:
raise skipped

Check warning on line 287 in src/_pytest/outcomes.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/outcomes.py#L287

Added line #L287 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, couldn't this be simplified by moving the raise Skipped(...) to directly after the warning?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per #12220 (comment), we need to raise this after the warning. 👍


mod = sys.modules[modname]
if minversion is None:
return mod
Expand Down
68 changes: 68 additions & 0 deletions testing/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import List
from typing import Tuple
from typing import Type
import warnings

from _pytest import outcomes
from _pytest import reports
Expand Down Expand Up @@ -762,6 +763,73 @@ def test_importorskip_imports_last_module_part() -> None:
assert os.path == ospath


class TestImportOrSkipExcType:
"""Tests for #11523."""

def test_no_warning(self) -> None:
# An attempt on a module which does not exist will raise ModuleNotFoundError, so it will
# be skipped normally and no warning will be issued.
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")

with pytest.raises(pytest.skip.Exception):
pytest.importorskip("TestImportOrSkipExcType_test_no_warning")

assert captured == []

def test_import_error_with_warning(self, pytester: Pytester) -> None:
# Create a module which exists and can be imported, however it raises
# ImportError due to some other problem. In this case we will issue a warning
# about the future behavior change.
fn = pytester.makepyfile("raise ImportError('some specific problem')")
pytester.syspathinsert()

with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")

with pytest.raises(pytest.skip.Exception):
pytest.importorskip(fn.stem)

[warning] = captured
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love this! Never seen it before, I've always done:

assert len(captured) == 1
warning = captured[0]

for this kind of thing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All credits go to https://www.cosmicpython.com, seen this pattern there and have been using it ever since. 😁

assert warning.category is pytest.PytestDeprecationWarning

def test_import_error_suppress_warning(self, pytester: Pytester) -> None:
# Same as test_import_error_with_warning, but we can suppress the warning
# by passing ImportError as exc_type.
fn = pytester.makepyfile("raise ImportError('some specific problem')")
pytester.syspathinsert()

with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")

with pytest.raises(pytest.skip.Exception):
pytest.importorskip(fn.stem, exc_type=ImportError)

assert captured == []

def test_warning_integration(self, pytester: Pytester) -> None:
pytester.makepyfile(
"""
import pytest
def test_foo():
pytest.importorskip("warning_integration_module")
"""
)
pytester.makepyfile(
warning_integration_module="""
raise ImportError("required library foobar not compiled properly")
"""
)
result = pytester.runpytest()
result.stdout.fnmatch_lines(
[
"*Module 'warning_integration_module' was found, but when imported by pytest it raised:",
"* ImportError('required library foobar not compiled properly')",
"*1 skipped, 1 warning*",
]
)


def test_importorskip_dev_module(monkeypatch) -> None:
try:
mod = types.ModuleType("mockmodule")
Expand Down