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 @@ -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
Expand Down Expand Up @@ -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(
Expand Down
60 changes: 60 additions & 0 deletions testing/test_assertrewrite.py
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,66 @@ 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")
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't validate the invatiant

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what is missing here for you.

  • The tests fails without the fix.
  • It reproduces the incorrect behaviour by moving the source and cached bytecode.

Which invariant do you mean?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test would pass without changes when one disables bytecode writing for example

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ill show a more detailed example once I get back to the computer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a check for the compiled bytecode. let me know what you think.

assert moved_pyc.stat().st_mtime_ns == pyc_mtime

@pytest.mark.skipif('"__pypy__" in sys.modules')
def test_pyc_vs_pyo(
self,
Expand Down