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

Conversation

shekhuverma
Copy link
Contributor

Closes #11523

Changed ImportError to ModuleNotFoundError in importorskip()``

Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

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

Thanks @shekhuverma.

Can you please add a test that shows importorskip will only skip on an explicit ModuleNotFoundError, but produce a failure for an ImportError exception?

Copy link
Member

@The-Compiler The-Compiler left a comment

Choose a reason for hiding this comment

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

I think at the very least, we need an easy escape hatch to get the former behavior, no? I'm somewhat ambivalent on adding a warning (after all, it's a loud failure - the only difference would be a warning vs. failing collection).

However, if someone really wants to catch ImportError here, they should be able to do pytest.importorskip("mymodule", exc=ImportError) or somesuch (with exc=, we might want to ensure that issubclass(exc, ImportError) maybe, at least on the type annotation level).

Right now, if someone wants to keep the existing behavior, they'd need to go from pytest.importorskip("mymodule") to something like (untested):

try:
    import mymodule
except ImportError:
    pytest.skip("some good reason here", allow_module_level=True)

or so, which seems quite burdensome.

@@ -762,6 +765,27 @@ def test_importorskip_imports_last_module_part() -> None:
assert os.path == ospath


def test_importorskip_importError_Exception() -> None:
## Mocking the import function to raise a importError
realimport = builtins.__import__
Copy link
Member

Choose a reason for hiding this comment

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

Please use monkeypatch instead of hand-rolling this.

Copy link
Member

@nicoddemus nicoddemus left a comment

Choose a reason for hiding this comment

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

Besides the changes requested, please also add a new changelog entry, as improvement.

Actually. I think we probably should not introduce the new behavior right away, instead capture ImportError and show a warning stating that the default behavior will change in future versions to catch ModuleNotFoundError instead.

testing/test_runner.py Outdated Show resolved Hide resolved
@@ -225,7 +225,7 @@ def importorskip(
warnings.simplefilter("ignore")
try:
__import__(modname)
except ImportError as exc:
except ModuleNotFoundError as exc:
Copy link
Member

Choose a reason for hiding this comment

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

Let's add a new parameter to this function, *, exc_type: Type[ImportError] = ModuleNotFoundError and use it here:

Suggested change
except ModuleNotFoundError as exc:
except exc_type as exc:

This way we provide a escape hatch to users which rely on the old behavior.

Also please update the docs, describing the parameters and explicitly showing an example for users that want the old behavior.

@nicoddemus
Copy link
Member

nicoddemus commented Apr 19, 2024

@The-Compiler and I left similar reviews almost at the same time hehehe.

I think we should actually not change the default behavior right away, for better or worse this is a breaking change.

I think introducing a new parameter exc_type: Optional[Type[ImportError]] = None, that when None:

  1. We capture ModuleNotFoundError first; in that case skip.
  2. We capture ImportError next, in that case issue a warning and also skip.

In 9.0, we turn the warning into an error, and 9.1 remove (2) from the code and let the ImportError propagate unchanged.

The benefits of this is that users would be able to use exc_type today in code, will not break anything that passes today (only a warning on ImportError), and provides a escape hatch for users that don't care and want to skip on any ImportError.

@shekhuverma
Copy link
Contributor Author

Thanks for the review.
I made the requested changes, let me know if there is something else to be done

@@ -223,12 +230,24 @@ def importorskip(
# of existing directories with the same name we're trying to
# import but without a __init__.py file.
warnings.simplefilter("ignore")

if exc_type is None:
exc_type = ModuleNotFoundError
Copy link
Member

Choose a reason for hiding this comment

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

Sorry I was not clearer.

I meant that we should only issue the warning if:

  1. The user is not passing an explicit exc_type.
  2. We actually capture ImportError.

So something like:

    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")

        if exc_type is None:
            exc_type = ImportError
            warn_on_import_error = True
        else:
            warn_on_import_error = False 

        try:
            __import__(modname)
        except exc_type as exc:
            if reason is None:
                reason = f"could not import {modname!r}: {exc}"
            if warn_on_import_error and type(exc) is ImportError:
                warnings.warn(
                    PytestDeprecationWarning(
                        f"pytest.importorskip() caught {exc}, but this will change in a future pytest release to only capture ModuleNotFoundError exceptions by default.\nTo overwrite the future behavior and silence this warning, pass exc_type=ImportError explicitly.",
                    )
                )
            raise Skipped(reason, allow_module_level=True) from None

So we will not be changing the behavior right now, only in the future.

Do you agree @The-Compiler?

Copy link
Member

Choose a reason for hiding this comment

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

Yep, that sounds like a solid approach. We'll also need tests for the warning and the different argument behaviours.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

   def importorskip(
    modname: str,
    minversion: Optional[str] = None,
    reason: Optional[str] = None,
    exc_type: Optional[Type[ImportError,ModuleNotFoundError]] = None,
    ) -> Any:
    .
    .
    .
    if exc_type is None:
       #to keep the old behavior
       exc_type = ImportError
       warn_on_import_error = True
    else:
        warn_on_import_error = False
        
    if warn_on_import_error or type(exc) is ImportError:
        warnings.warn()

I think the function should be like this, showing a warning if the user doesn't pass any exc_type or when there is ImportError

Copy link
Member

Choose a reason for hiding this comment

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

We do not want to issue the warning unless we actually caught ImportError.

Most of the time the exception being raised is ModuleNotFoundError, which is OK and what users expect.

The problem is when the library is found, but ImportError is raised because of a related problem (for example failure to load a required a shared library). In this case we want to issue a warning now, and in pytest 9.0, let the exception propagate upwards.

The exc_type parameter will let users be explicit about what they want to capture.

@nicoddemus
Copy link
Member

@shekhuverma thanks for following up!

Took the liberty of adding more tests, docs, and change the implementation a bit.

@The-Compiler would appreciate another review.

@nicoddemus
Copy link
Member

After this gets merged I will create an issue to track changing the default in 9.1.

@nicoddemus nicoddemus added this to the 8.2 milestone Apr 20, 2024
@shekhuverma
Copy link
Contributor Author

Thanks for the help @The-Compiler and @nicoddemus.
This was my first open-source contribution.
Please mark more issues that I can solve

@shekhuverma
Copy link
Contributor Author

Hello there, what are the next steps?
when will it get merged to the main?

@nicoddemus
Copy link
Member

The ball is on our side now @shekhuverma, waiting on @The-Compiler's review.

@nicoddemus
Copy link
Member

(Note to ourselves, we need to squash merge this)

@The-Compiler
Copy link
Member

Sorry for the delay! I plan to look at this today, now that PyConDE is over.

Copy link
Member

@The-Compiler The-Compiler left a comment

Choose a reason for hiding this comment

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

Thanks @shekhuverma and @nicoddemus. I have a couple of remaining questions and comments (most of them minor, but one semantic thing that I think we should aim to get correct).

doc/en/deprecations.rst Outdated Show resolved Hide resolved
"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))
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

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)
if skipped:
raise skipped
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. 👍

src/_pytest/outcomes.py Outdated Show resolved Hide resolved
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. 😁

# 2. Remove `warn_on_import` and the warning handling.
if exc_type is None:
exc_type = ImportError
warn_on_import_error = True
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

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.

Co-authored-by: Florian Bruhin <me@the-compiler.org>
@shekhuverma
Copy link
Contributor Author

Can you please mark more related issues/improvements that I can try?
I tried searching in the issues section but failed to choose one.
Thanks

@The-Compiler The-Compiler merged commit 4eb8b6d into pytest-dev:main Apr 26, 2024
24 checks passed
@The-Compiler
Copy link
Member

Thanks again! 👍 We have the good first issue label, though indeed I think we haven't really been doing a good job at applying it to issues recently...

@shekhuverma
Copy link
Contributor Author

Thanks again! 👍 We have the good first issue label, though indeed I think we haven't really been doing a good job at applying it to issues recently...

Yes, there is only one issue with that label now

The-Compiler added a commit to qutebrowser/qutebrowser that referenced this pull request Apr 30, 2024
With pytest 8.2, pytest.importorskip(...) now only considers ModuleNotFoundError
rather than all ImportErrors, and warns otherwise:
pytest-dev/pytest#12220

While we could override this via

    pytest.importorskip(..., exc_type=machinery.Unavailable)

this is a simpler solution, and it also makes more sense semantically:

We only raise Unavailable when an import is being done that would otherwise
result in a ModuleNotFoundError anyways (e.g. trying to import QtWebKit on Qt
6).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

importorskip variant that uses ModuleNotFoundError
3 participants