diff --git a/changelog/14632.bugfix.rst b/changelog/14632.bugfix.rst new file mode 100644 index 00000000000..250719303de --- /dev/null +++ b/changelog/14632.bugfix.rst @@ -0,0 +1,3 @@ +pytest no longer crashes on startup when lazy imports are enabled (:pep:`810`, ``PYTHON_LAZY_IMPORTS=all`` on Python 3.15+). + +Assertion rewriting now skips the standard library and pytest's own modules, unless they are registered with :func:`pytest.register_assert_rewrite`. diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 27953336c5c..41ace392d2c 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -63,6 +63,10 @@ PYC_EXT = ".py" + ((__debug__ and "c") or "o") PYC_TAIL = "." + PYTEST_TAG + PYC_EXT +# Top level packages that never hold test code. Answering for them without any +# import or path lookup is what stops find_spec() from recursing (#14632). +_NEVER_REWRITTEN_ROOTS = frozenset({"pytest", "_pytest"}) | sys.stdlib_module_names + class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader): """PEP302/PEP451 import hook which rewrites asserts.""" @@ -186,6 +190,11 @@ def _early_rewrite_bailout(self, name: str, state: AssertionState) -> bool: tries to filter what we're sure won't be rewritten before getting to it. """ + # No imports or path lookups here (see _NEVER_REWRITTEN_ROOTS), but an + # explicit register_assert_rewrite() still wins. + if name.partition(".")[0] in _NEVER_REWRITTEN_ROOTS: + return not self._is_marked_for_rewrite(name, state) + if self.session is not None and not self._session_paths_checked: self._session_paths_checked = True for initial_path in self.session._initialpaths: diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index c9736f8fa48..21859c53f10 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1937,6 +1937,28 @@ def fix(): return 1 assert hook.find_spec("foobar") is not None assert self.find_spec_calls == ["conftest", "test_foo", "foobar"] + def test_stdlib_and_pytest_modules( + self, pytester: Pytester, hook: AssertionRewritingHook + ) -> None: + """Bail out without consulting PathFinder, even for a catch-all + "python_files" pattern that would match them (#14632).""" + with mock.patch.object(hook, "fnpats", ["*.py"]): + assert hook.find_spec("fnmatch") is None + assert hook.find_spec("os.path") is None + assert hook.find_spec("pytest") is None + assert hook.find_spec("_pytest.pathlib") is None + assert self.find_spec_calls == [] + + def test_marked_for_rewrite_beats_stdlib_name( + self, pytester: Pytester, hook: AssertionRewritingHook + ) -> None: + """A registered module shadowing a stdlib name is still rewritten (#14632).""" + pytester.makepyfile(turtle="def check(x): assert x") + hook.mark_rewrite("turtle") + + assert hook.find_spec("turtle") is not None + assert self.find_spec_calls == ["turtle"] + def test_pattern_contains_subdirectories( self, pytester: Pytester, hook: AssertionRewritingHook ) -> None: @@ -2390,3 +2412,27 @@ def test(): ) reprec = pytester.inline_run("-p", "no:terminalreporter") reprec.assertoutcome(passed=1) + + +def test_lazy_imports_keep_assertion_rewriting_working( + pytester: Pytester, monkeypatch: pytest.MonkeyPatch +) -> None: + """Assertion rewriting survives PEP 810 lazy imports (#14632). + + Interpreters without lazy imports ignore the variable, making this a smoke + test there. + """ + pytester.makepyfile( + """ + def test_rewritten(): + x = 1 + assert x == 2 + """ + ) + monkeypatch.setenv("PYTHON_LAZY_IMPORTS", "all") + + result = pytester.runpytest_subprocess() + + # only a rewritten assertion reports the value of `x` + result.stdout.fnmatch_lines(["E*assert 1 == 2"]) + result.assert_outcomes(failed=1)