From 3d0f2cf7f48bce129471672ba25cce4c1bff31be Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 8 Sep 2026 08:06:28 +0200 Subject: [PATCH 1/3] fix: bytecode cache invalidation for moved test files (#14552) --- changelog/14552.bugfix.rst | 1 + src/_pytest/assertion/rewrite.py | 7 +++++++ testing/test_assertrewrite.py | 25 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 changelog/14552.bugfix.rst diff --git a/changelog/14552.bugfix.rst b/changelog/14552.bugfix.rst new file mode 100644 index 00000000000..8e8f01d9d19 --- /dev/null +++ b/changelog/14552.bugfix.rst @@ -0,0 +1 @@ +Fixed assertion-rewrite cache invalidation for moved test files: a rewritten ``.pyc`` whose ``co_filename`` no longer matches the current source path is now treated as stale and rewritten. Previously, renaming or moving a test module/directory could leak the old path into ``inspect.currentframe().f_code.co_filename`` and related traceback/reporting paths. diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 27953336c5c..1709c6c4b5e 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -379,6 +379,13 @@ def _read_pyc( if not isinstance(co, types.CodeType): trace(f"_read_pyc({source}): not a code object") return None + # A cached pyc can be moved together with the source file (for example + # by renaming a package or test directory). In that case the marshaled + # code object's ``co_filename`` still points to the old source path. + # Treat that as stale so the caller rewrites and recreates the cache. + if co.co_filename != str(source): + trace(f"_read_pyc({source}): stale filename {co.co_filename!r}") + return None return co diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index c9736f8fa48..98e7b54589e 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1161,6 +1161,31 @@ def test_foo(): glob.glob("__pycache__/*.pyc") ) + def test_moved_test_file_updates_code_filename( + self, pytester: Pytester, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Moving a test module must keep ``co_filename`` synchronized with ``__file__``.""" + monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + + pytester.makepyfile( + **{ + "test1/test_a.py": """ + from inspect import currentframe + + def test_a(): + assert currentframe().f_code.co_filename == __file__ + """ + } + ) + + first = pytester.runpytest_subprocess("-s", "test1/test_a.py") + first.assert_outcomes(passed=1) + + pytester.path.joinpath("test1").rename(pytester.path.joinpath("test2")) + + second = pytester.runpytest_subprocess("-s", "test2/test_a.py") + second.assert_outcomes(passed=1) + @pytest.mark.skipif('"__pypy__" in sys.modules') def test_pyc_vs_pyo( self, From bb33e5c1feb7223829b9ffe43cf0b4df2bf8df50 Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 15 Sep 2026 09:27:03 +0200 Subject: [PATCH 2/3] test: check that bytecode is actually written --- testing/test_assertrewrite.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 98e7b54589e..d2683f1d1ba 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1165,9 +1165,10 @@ def test_moved_test_file_updates_code_filename( self, pytester: Pytester, monkeypatch: pytest.MonkeyPatch ) -> None: """Moving a test module must keep ``co_filename`` synchronized with ``__file__``.""" + monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) - pytester.makepyfile( + source = pytester.makepyfile( **{ "test1/test_a.py": """ from inspect import currentframe @@ -1181,8 +1182,15 @@ def test_a(): first = pytester.runpytest_subprocess("-s", "test1/test_a.py") first.assert_outcomes(passed=1) + pyc = get_cache_dir(source) / ("test_a" + PYC_TAIL) + assert pyc.is_file() + pytester.path.joinpath("test1").rename(pytester.path.joinpath("test2")) + moved_source = pytester.path / "test2" / "test_a.py" + moved_pyc = get_cache_dir(moved_source) / ("test_a" + PYC_TAIL) + assert moved_pyc.is_file() + second = pytester.runpytest_subprocess("-s", "test2/test_a.py") second.assert_outcomes(passed=1) From 3261273a1f5ec51d75f5744bab41ca243d84942a Mon Sep 17 00:00:00 2001 From: Frank Hoffmann <15r10nk-git@polarbit.de> Date: Tue, 15 Sep 2026 21:42:53 +0200 Subject: [PATCH 3/3] fix: use _imp._fix_co_filename to fix the filename in the bytecode and use a pure python implementation as fallback Co-authored-by: Grok Build --- changelog/14552.bugfix.rst | 2 +- src/_pytest/assertion/rewrite.py | 39 ++++++++++++++++++++++++++++---- testing/test_assertrewrite.py | 33 +++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/changelog/14552.bugfix.rst b/changelog/14552.bugfix.rst index 8e8f01d9d19..6c624caa5a5 100644 --- a/changelog/14552.bugfix.rst +++ b/changelog/14552.bugfix.rst @@ -1 +1 @@ -Fixed assertion-rewrite cache invalidation for moved test files: a rewritten ``.pyc`` whose ``co_filename`` no longer matches the current source path is now treated as stale and rewritten. Previously, renaming or moving a test module/directory could leak the old path into ``inspect.currentframe().f_code.co_filename`` and related traceback/reporting paths. +Fixed stale ``co_filename`` values after a test module or directory is moved. Assertion-rewrite caches are reused when the source hash still matches, and in-memory code objects are pointed at the current path. diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 1709c6c4b5e..476ea9c3547 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -56,6 +56,14 @@ from _pytest.assertion import AssertionState +try: + from _imp import ( # type: ignore[attr-defined] + _fix_co_filename as _imp_fix_co_filename, + ) +except ImportError: # pragma: no cover + _imp_fix_co_filename = None + + assertstate_key = StashKey["AssertionState"]() # pytest caches rewritten pycs in pycache dirs @@ -382,11 +390,34 @@ def _read_pyc( # A cached pyc can be moved together with the source file (for example # by renaming a package or test directory). In that case the marshaled # code object's ``co_filename`` still points to the old source path. - # Treat that as stale so the caller rewrites and recreates the cache. - if co.co_filename != str(source): - trace(f"_read_pyc({source}): stale filename {co.co_filename!r}") - return None + # Fix it in memory the same way importlib does for ordinary pycs: the + # cache stays valid, only the in-memory location is corrected. + return _fix_code_filename(co, str(source)) + + +def _replace_code_filenames(co: types.CodeType, filename: str) -> types.CodeType: + """Pure-Python fallback: rebuild the code object tree with *filename*.""" + return co.replace( + co_filename=filename, + co_consts=tuple( + _replace_code_filenames(c, filename) if isinstance(c, types.CodeType) else c + for c in co.co_consts + ), + ) + + +def _fix_code_filename(co: types.CodeType, filename: str) -> types.CodeType: + """Point *co* and its nested code objects at *filename*. + + Mirrors what importlib does for every pyc it loads: the cache stays valid, + only the in-memory location is corrected. + """ + if co.co_filename == filename: + return co + if _imp_fix_co_filename is not None: + _imp_fix_co_filename(co, filename) # in place, recursive, C return co + return _replace_code_filenames(co, filename) def rewrite_asserts( diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index d2683f1d1ba..1c2b12b5969 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1161,13 +1161,39 @@ def test_foo(): glob.glob("__pycache__/*.pyc") ) + @pytest.mark.parametrize("implementation", ["import-lib", "pure-python"]) def test_moved_test_file_updates_code_filename( - self, pytester: Pytester, monkeypatch: pytest.MonkeyPatch + self, + pytester: Pytester, + monkeypatch: pytest.MonkeyPatch, + implementation: str, ) -> None: - """Moving a test module must keep ``co_filename`` synchronized with ``__file__``.""" + """Moving a test module must keep ``co_filename`` synchronized with ``__file__``. + + The rewritten pyc is reused: filenames are corrected in memory, not by + rewriting the cache. + """ + from _pytest.assertion.rewrite import ( # type: ignore[attr-defined] + _imp_fix_co_filename, + ) + monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) monkeypatch.delenv("PYTHONPYCACHEPREFIX", raising=False) + if implementation == "import-lib": + if _imp_fix_co_filename is None: + pytest.skip( + "_imp._fix_co_filename is not available" + ) # pragma: no cover + else: + # The inner pytest runs in a subprocess, so patch there. + pytester.makeconftest( + """ + import _pytest.assertion.rewrite as rewrite + rewrite._imp_fix_co_filename = None + """ + ) + source = pytester.makepyfile( **{ "test1/test_a.py": """ @@ -1185,6 +1211,8 @@ def test_a(): pyc = get_cache_dir(source) / ("test_a" + PYC_TAIL) assert pyc.is_file() + pyc_mtime = pyc.stat().st_mtime_ns + pytester.path.joinpath("test1").rename(pytester.path.joinpath("test2")) moved_source = pytester.path / "test2" / "test_a.py" @@ -1193,6 +1221,7 @@ def test_a(): second = pytester.runpytest_subprocess("-s", "test2/test_a.py") second.assert_outcomes(passed=1) + assert moved_pyc.stat().st_mtime_ns == pyc_mtime @pytest.mark.skipif('"__pypy__" in sys.modules') def test_pyc_vs_pyo(