diff --git a/changelog/14632.bugfix.rst b/changelog/14632.bugfix.rst new file mode 100644 index 00000000000..cf149dd14b9 --- /dev/null +++ b/changelog/14632.bugfix.rst @@ -0,0 +1,5 @@ +Fixed a crash on startup when running under lazy imports (:pep:`810`, ``PYTHON_LAZY_IMPORTS=all`` on Python 3.15+). + +Resolving a lazy import runs the meta path finders, so the assertion rewriting hook was asked to find the very modules it needs in order to answer -- which ended in unbounded recursion or an ``ImportCycleError``. + +Modules of the standard library and of pytest itself are now skipped up front and never rewritten, which breaks that cycle and also speeds up importing them slightly. Modules registered via :func:`pytest.register_assert_rewrite` keep being rewritten even if they shadow a stdlib name. diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index dcc37df2c98..f6a676b460d 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -72,6 +72,18 @@ class Sentinel: # Special marker that denotes we have just left a scope definition _SCOPE_END_MARKER = Sentinel() +# Top level packages that never contain test code, so they can be excluded from +# rewriting without looking at the file system at all. +# +# Besides being a small speedup, this is what keeps ``find_spec()`` from calling +# itself: with PEP 810 lazy imports enabled (``PYTHON_LAZY_IMPORTS=all``, Python +# 3.15+) a plain attribute access can resolve an import, and resolving an import +# runs the meta path finders - this one included. The names ``find_spec()`` +# needs in order to answer at all (``_pytest.pathlib.fnmatch_ex``, and +# ``fnmatch`` in turn) are exactly the ones it would then be asked about, +# which used to end in unbounded recursion resp. an ``ImportCycleError`` (#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.""" @@ -195,6 +207,13 @@ 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. """ + # Answered without touching anything else, so that this stays usable + # while the imports the checks below rely on are still being resolved + # (see _NEVER_REWRITTEN_ROOTS). Explicit `register_assert_rewrite()` + # still wins, so a local module shadowing a stdlib name keeps working. + 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 9740bf3c05e..d0fbed74b31 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1993,6 +1993,32 @@ 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: + """The stdlib and pytest itself never hold test code, so they 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 local module shadowing a stdlib name is still rewritten when it was + explicitly registered via `register_assert_rewrite` (#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: @@ -2446,3 +2472,29 @@ 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). + + With ``PYTHON_LAZY_IMPORTS=all`` (Python 3.15+) an attribute access can + resolve an import, so the rewrite hook is asked for the very modules it + needs to answer at all. Interpreters without lazy imports ignore the + environment variable, which makes this a plain smoke test there. + """ + pytester.makepyfile( + """ + def test_rewritten(): + x = 1 + assert x == 2 + """ + ) + monkeypatch.setenv("PYTHON_LAZY_IMPORTS", "all") + + result = pytester.runpytest_subprocess() + + # the rewritten assertion reports the value of `x`, an unrewritten one would not + result.stdout.fnmatch_lines(["E*assert 1 == 2"]) + result.assert_outcomes(failed=1)