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
1 change: 1 addition & 0 deletions changelog/14552.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 38 additions & 0 deletions src/_pytest/assertion/rewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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


class Sentinel:
pass

Expand Down Expand Up @@ -397,7 +405,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(
Expand Down
62 changes: 62 additions & 0 deletions testing/test_assertrewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,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,
Expand Down