Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/14632.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions src/_pytest/assertion/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions testing/test_assertrewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)