Skip to content

assertion rewrite: never rewrite stdlib and pytest modules (fixes lazy imports crash) - #14844

Open
RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:fix-14632-lazy-import-recursion
Open

assertion rewrite: never rewrite stdlib and pytest modules (fixes lazy imports crash)#14844
RonnyPfannschmidt wants to merge 1 commit into
pytest-dev:mainfrom
RonnyPfannschmidt:fix-14632-lazy-import-recursion

Conversation

@RonnyPfannschmidt

Copy link
Copy Markdown
Member

Fixes #14632. Supersedes #14633, whose approach this builds on — same idea, with the skip list widened to close the remaining half of the cycle and register_assert_rewrite() kept working (details below).

With lazy imports enabled (PEP 810, PYTHON_LAZY_IMPORTS=all on Python 3.15+) pytest dies during startup, before it gets to run anything:

$ PYTHON_LAZY_IMPORTS=all pytest --help
...
  File "src/_pytest/assertion/rewrite.py", line 111, in find_spec
    if self._early_rewrite_bailout(name, state):
  File "src/_pytest/assertion/rewrite.py", line 229, in _early_rewrite_bailout
    if fnmatch_ex(pat, path):
  File "src/_pytest/pathlib.py", line 452, in fnmatch_ex
    return fnmatch.fnmatch(name, pattern)
ImportCycleError: cannot import name 'fnmatch' (most likely due to a circular import)

(3.15.0b2 as in the issue report recursed until it blew the stack; 3.15.0rc1 detects the cycle and raises.)

Why it happens

Under lazy imports an ordinary attribute access can resolve an import, and resolving an import runs the meta path finders — including AssertionRewritingHook. So find_spec() gets asked about the very modules it needs in order to answer:

find_spec("test_foo")
  → _early_rewrite_bailout("test_foo")
    → fnmatch_ex(...)                 # resolves _pytest.pathlib, then fnmatch
      → find_spec("fnmatch")
        → _early_rewrite_bailout("fnmatch")
          → fnmatch_ex(...)           # ← same unresolved name, cycle

The hook has no way to answer that question without first answering it.

The fix

A stateless skip list of top level packages that never contain test code:

_NEVER_REWRITTEN_ROOTS = frozenset({"pytest", "_pytest"}) | sys.stdlib_module_names

_early_rewrite_bailout() consults it before anything else, so the answer for those names needs no imports, no path lookups and no filesystem access. That makes the recursion impossible rather than merely unlikely: everything reachable from find_spec() is either stdlib or _pytest.*, and both are covered.

Two details worth flagging for review, both differences to #14633:

  • _pytest/pytest are in the list on purpose. Skipping only the stdlib fixes the traceback above, but the outer half of the cycle — resolving _pytest.pathlib in order to call fnmatch_ex — is left in place. Today we get away with it because _pytest.pathlib happens to be imported before the hook is installed (verified: at the first find_spec() call _pytest.pathlib is in sys.modules, fnmatch is not). That is an accident of import order, not an invariant.

  • register_assert_rewrite() still wins, so the bailout is return not self._is_marked_for_rewrite(name, state), not return True. sys.stdlib_module_names holds ~300 names including code, platform, types, string, json and logging, and a local top level module shadows the stdlib one (sys.path[0] beats the stdlib for pure-Python modules). An unconditional skip would silently drop rewriting for such a package. The check is a cached dict lookup and only runs for names already known to be in the skip list.

    (test is not in sys.stdlib_module_names — CPython excludes its own test package — so the common top level test/ package layout was never affected.)

Besides fixing the crash this is a small speedup: every stdlib and _pytest import during a run now skips the PurePath construction and the fnmatch loop.

Behaviour change

Stdlib and pytest modules are no longer rewritten under a catch-all python_files pattern such as python_files = *.py. This was possible before, is essentially never wanted, and is now impossible.

Testing

  • TestEarlyRewriteBailout::test_stdlib_and_pytest_modules — uses the existing PathFinder.find_spec spy to assert those names bail out without a path lookup, with python_files = *.py so the old code would have rewritten them. Fails on main.
  • TestEarlyRewriteBailout::test_marked_for_rewrite_beats_stdlib_name — a registered module shadowing a stdlib name is still rewritten.
  • test_lazy_imports_keep_assertion_rewriting_working — runs a failing test in a subprocess with PYTHON_LAZY_IMPORTS=all and asserts the rewritten output (assert 1 == 2), so it covers both the crash and the rewriting still doing its job. No version guard: older interpreters ignore the variable and it degrades to a smoke test. Fails on main under 3.15.

Manually verified against CPython 3.15.0rc1: PYTHON_LAZY_IMPORTS=all pytest --help works, and a failing assertion still gets full introspection.

Unrelated 3.15 findings

Running pytest's own suite under PYTHON_LAZY_IMPORTS=all on 3.15.0rc1 leaves 32 failures. They are identical with and without this change and none involve assertion rewriting — they are tests that assert on eager ImportError behaviour, which PEP 810 defers by design (e.g. test_monkeypatch.py::test_importerror now collects cleanly because import doesnotexist no longer raises at import time). Worth a separate issue rather than blocking this one.

hypothesis also fails to import under PYTHON_LAZY_IMPORTS=all (its dir()-based export check in hypothesis/strategies/__init__.py sees unresolved lazy names), which is why testing/acceptance_test.py and testing/python/metafunc.py were excluded from that run.

🤖 Generated with Claude Code

Under lazy imports (PEP 810, PYTHON_LAZY_IMPORTS=all) an attribute access
can resolve an import, and resolving an import runs the meta path finders.
AssertionRewritingHook.find_spec was therefore asked about the very modules
it needs in order to answer - fnmatch_ex and fnmatch - which recursed until
the stack blew resp. raised ImportCycleError, before pytest could start.

Bail out for those names up front, without imports, path lookups or
filesystem access, so the cycle cannot form: everything find_spec touches
is either stdlib or _pytest.*. Explicit register_assert_rewrite() still
wins, so a local module shadowing a stdlib name keeps being rewritten.

As a side effect stdlib and pytest imports no longer pay for the PurePath
construction and fnmatch loop of the early bailout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pytest does not work with PYTHON_LAZY_IMPORTS=all

1 participant