diff --git a/changelog/14552.bugfix.rst b/changelog/14552.bugfix.rst new file mode 100644 index 00000000000..6c624caa5a5 --- /dev/null +++ b/changelog/14552.bugfix.rst @@ -0,0 +1 @@ +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 27953336c5c..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 @@ -379,7 +387,37 @@ 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. + # 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 c9736f8fa48..1c2b12b5969 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1161,6 +1161,68 @@ 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, + implementation: str, + ) -> None: + """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": """ + 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) + + 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" + 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) + assert moved_pyc.stat().st_mtime_ns == pyc_mtime + @pytest.mark.skipif('"__pypy__" in sys.modules') def test_pyc_vs_pyo( self,