From 0be71aeb9e2fc37838fc17d1eb04fb5afd63c252 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 01:15:46 +0200 Subject: [PATCH 1/3] Add hash comparison for pyc cache files --- changelog/11418.improvement.rst | 1 + src/_pytest/assertion/rewrite.py | 37 +++++++++++++++++++++----------- testing/test_assertrewrite.py | 34 +++++++++++++++++++---------- 3 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 changelog/11418.improvement.rst diff --git a/changelog/11418.improvement.rst b/changelog/11418.improvement.rst new file mode 100644 index 00000000000..1bdb0b60602 --- /dev/null +++ b/changelog/11418.improvement.rst @@ -0,0 +1 @@ +Added hash comparison for pyc cache files. diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index dcc37df2c98..65aa7270273 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -176,11 +176,11 @@ def exec_module(self, module: types.ModuleType) -> None: co = _read_pyc(fn, pyc, state.trace) if co is None: state.trace(f"rewriting {fn!r}") - source_stat, co = _rewrite_test(fn, self.config) + source_stat, source_hash, co = _rewrite_test(fn, self.config) if write: self._writing_pyc = True try: - _write_pyc(state, co, source_stat, pyc) + _write_pyc(state, co, source_stat, source_hash, pyc) finally: self._writing_pyc = False else: @@ -298,7 +298,7 @@ def get_resource_reader(self, name: str) -> TraversableResources: def _write_pyc_fp( - fp: IO[bytes], source_stat: os.stat_result, co: types.CodeType + fp: IO[bytes], source_stat: os.stat_result, source_hash: bytes, co: types.CodeType ) -> None: # Technically, we don't have to have the same pyc format as # (C)Python, since these "pycs" should never be seen by builtin @@ -310,8 +310,11 @@ def _write_pyc_fp( # as of now, bytecode header expects 32-bit numbers for size and mtime (#4903) mtime = int(source_stat.st_mtime) & 0xFFFFFFFF size = source_stat.st_size & 0xFFFFFFFF + # 64-bit source file hash + source_hash = source_hash[:8] # " bool: proc_pyc = f"{pyc}.{os.getpid()}" try: with open(proc_pyc, "wb") as fp: - _write_pyc_fp(fp, source_stat, co) + _write_pyc_fp(fp, source_stat, source_hash, co) except OSError as e: state.trace(f"error writing pyc file at {proc_pyc}: errno={e.errno}") return False @@ -340,15 +344,18 @@ def _write_pyc( return True -def _rewrite_test(fn: Path, config: Config) -> tuple[os.stat_result, types.CodeType]: +def _rewrite_test( + fn: Path, config: Config +) -> tuple[os.stat_result, bytes, types.CodeType]: """Read and rewrite *fn* and return the code object.""" stat = os.stat(fn) source = fn.read_bytes() + source_hash = importlib.util.source_hash(source) strfn = str(fn) tree = ast.parse(source, filename=strfn) rewrite_asserts(tree, source, strfn, config) co = compile(tree, strfn, "exec", dont_inherit=True) - return stat, co + return stat, source_hash, co def _read_pyc( @@ -367,12 +374,12 @@ def _read_pyc( stat_result = os.stat(source) mtime = int(stat_result.st_mtime) size = stat_result.st_size - data = fp.read(16) + data = fp.read(24) except OSError as e: trace(f"_read_pyc({source}): OSError {e}") return None # Check for invalid or out of date pyc file. - if len(data) != (16): + if len(data) != (24): trace(f"_read_pyc({source}): invalid pyc (too short)") return None if data[:4] != importlib.util.MAGIC_NUMBER: @@ -381,14 +388,20 @@ def _read_pyc( if data[4:8] != b"\x00\x00\x00\x00": trace(f"_read_pyc({source}): invalid pyc (unsupported flags)") return None - mtime_data = data[8:12] - if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF: - trace(f"_read_pyc({source}): out of date") - return None size_data = data[12:16] if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF: trace(f"_read_pyc({source}): invalid pyc (incorrect size)") return None + mtime_data = data[8:12] + if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF: + trace(f"_read_pyc({source}): out of date") + hash = data[16:24] + source_hash = importlib.util.source_hash(source.read_bytes()) + if source_hash[:8] == hash: + trace(f"_read_pyc({source}): source hash match (no change detected)") + else: + trace(f"_read_pyc({source}): hash doesn't match") + return None try: co = marshal.load(fp) except Exception as e: diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 9740bf3c05e..161bf5d591b 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -9,6 +9,7 @@ from functools import partial import glob import importlib +from importlib.util import source_hash import inspect import marshal import os @@ -1325,12 +1326,14 @@ def test_write_pyc(self, pytester: Pytester, tmp_path) -> None: state = AssertionState(config, "rewrite") tmp_path.joinpath("source.py").touch() source_path = str(tmp_path) + source_bytes = tmp_path.joinpath("source.py").read_bytes() pycpath = tmp_path.joinpath("pyc") co = compile("1", "f.py", "single") - assert _write_pyc(state, co, os.stat(source_path), pycpath) + hash = source_hash(source_bytes) + assert _write_pyc(state, co, os.stat(source_path), hash, pycpath) with mock.patch.object(os, "replace", side_effect=OSError): - assert not _write_pyc(state, co, os.stat(source_path), pycpath) + assert not _write_pyc(state, co, os.stat(source_path), hash, pycpath) def test_resources_provider_for_loader(self, pytester: Pytester) -> None: """ @@ -1403,8 +1406,15 @@ def test_read_pyc_success(self, tmp_path: Path, pytester: Pytester) -> None: fn.write_text("def test(): assert True", encoding="utf-8") - source_stat, co = _rewrite_test(fn, config) - _write_pyc(state, co, source_stat, pyc) + source_stat, hash, co = _rewrite_test(fn, config) + _write_pyc(state, co, source_stat, hash, pyc) + assert _read_pyc(fn, pyc, state.trace) is not None + + # pyc read should still work if only the mtime changed + # Fallback to hash comparison + new_mtime = source_stat.st_mtime + 1.2 + os.utime(fn, (new_mtime, new_mtime)) + assert source_stat.st_mtime != os.stat(fn).st_mtime assert _read_pyc(fn, pyc, state.trace) is not None def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: @@ -1425,11 +1435,13 @@ def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: os.utime(source, (mtime_int, mtime_int)) size = len(source_bytes).to_bytes(4, "little") + hash = source_hash(source_bytes) + hash = hash[:8] code = marshal.dumps(compile(source_bytes, str(source), "exec")) # Good header. - pyc.write_bytes(magic + flags + mtime + size + code) + pyc.write_bytes(magic + flags + mtime + size + hash + code) assert _read_pyc(source, pyc, print) is not None # Too short. @@ -1437,19 +1449,19 @@ def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: assert _read_pyc(source, pyc, print) is None # Bad magic. - pyc.write_bytes(b"\x12\x34\x56\x78" + flags + mtime + size + code) + pyc.write_bytes(b"\x12\x34\x56\x78" + flags + mtime + size + hash + code) assert _read_pyc(source, pyc, print) is None # Unsupported flags. - pyc.write_bytes(magic + b"\x00\xff\x00\x00" + mtime + size + code) + pyc.write_bytes(magic + b"\x00\xff\x00\x00" + mtime + size + hash + code) assert _read_pyc(source, pyc, print) is None - # Bad mtime. - pyc.write_bytes(magic + flags + b"\x58\x3d\xb0\x5f" + size + code) + # Bad size. + pyc.write_bytes(magic + flags + mtime + b"\x99\x00\x00\x00" + hash + code) assert _read_pyc(source, pyc, print) is None - # Bad size. - pyc.write_bytes(magic + flags + mtime + b"\x99\x00\x00\x00" + code) + # Bad mtime + bad hash. + pyc.write_bytes(magic + flags + b"\x58\x3d\xb0\x5f" + size + b"\x00" * 8 + code) assert _read_pyc(source, pyc, print) is None def test_reload_is_same_and_reloads(self, pytester: Pytester) -> None: From 32f60ccfed82ecbf8397acd9b3ad31d4d42124a2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 10 Sep 2023 23:48:15 +0200 Subject: [PATCH 2/3] Add invalidation-mode option --- src/_pytest/assertion/__init__.py | 1 + src/_pytest/assertion/rewrite.py | 73 +++++++++++++++--------- src/_pytest/main.py | 8 +++ testing/test_assertrewrite.py | 95 ++++++++++++++++++++++++++----- 4 files changed, 135 insertions(+), 42 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index a171633f320..375d1363a54 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -128,6 +128,7 @@ class AssertionState: def __init__(self, config: Config, mode) -> None: self.mode = mode self.trace = config.trace.root.get("assertion") + self.invalidation_mode = config.known_args_namespace.invalidationmode self.hook: rewrite.AssertionRewritingHook | None = None diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 65aa7270273..67e6cd306ce 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -24,6 +24,7 @@ import tokenize import types from typing import IO +from typing import Literal from typing import TYPE_CHECKING @@ -37,6 +38,8 @@ from importlib.resources.readers import FileReader +import _imp + from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited @@ -298,23 +301,31 @@ def get_resource_reader(self, name: str) -> TraversableResources: def _write_pyc_fp( - fp: IO[bytes], source_stat: os.stat_result, source_hash: bytes, co: types.CodeType + fp: IO[bytes], + source_stat: os.stat_result, + source_hash: bytes, + co: types.CodeType, + invalidation_mode: Literal["timestamp", "checked-hash"], ) -> None: # Technically, we don't have to have the same pyc format as # (C)Python, since these "pycs" should never be seen by builtin # import. However, there's little reason to deviate. fp.write(importlib.util.MAGIC_NUMBER) # https://www.python.org/dev/peps/pep-0552/ - flags = b"\x00\x00\x00\x00" - fp.write(flags) - # as of now, bytecode header expects 32-bit numbers for size and mtime (#4903) - mtime = int(source_stat.st_mtime) & 0xFFFFFFFF - size = source_stat.st_size & 0xFFFFFFFF - # 64-bit source file hash - source_hash = source_hash[:8] - # " None: help="Prepend/append to sys.path when importing test modules and conftest " "files. Default: prepend.", ) + group.addoption( + "--invalidation-mode", + default="timestamp", + choices=["timestamp", "checked-hash"], + dest="invalidationmode", + help="Pytest pyc cache invalidation mode. Default: timestamp.", + ) + parser.addini( "norecursedirs", "Directory patterns to avoid for recursion", diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 161bf5d591b..a5f12148cc0 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1,6 +1,7 @@ # mypy: allow-untyped-defs from __future__ import annotations +import _imp import ast from collections.abc import Generator from collections.abc import Mapping @@ -1410,13 +1411,37 @@ def test_read_pyc_success(self, tmp_path: Path, pytester: Pytester) -> None: _write_pyc(state, co, source_stat, hash, pyc) assert _read_pyc(fn, pyc, state.trace) is not None - # pyc read should still work if only the mtime changed - # Fallback to hash comparison - new_mtime = source_stat.st_mtime + 1.2 - os.utime(fn, (new_mtime, new_mtime)) - assert source_stat.st_mtime != os.stat(fn).st_mtime + pyc_bytes = pyc.read_bytes() + assert pyc_bytes[4] == 0 # timestamp flag set + + def test_read_pyc_success_hash(self, tmp_path: Path, pytester: Pytester) -> None: + from _pytest.assertion import AssertionState + from _pytest.assertion.rewrite import _read_pyc + from _pytest.assertion.rewrite import _rewrite_test + from _pytest.assertion.rewrite import _write_pyc + + config = pytester.parseconfig("--invalidation-mode=checked-hash") + state = AssertionState(config, "rewrite") + + fn = tmp_path / "source.py" + pyc = Path(str(fn) + "c") + + # Test private attribute didn't change + assert getattr(_imp, "check_hash_based_pycs", None) in { + "default", + "always", + "never", + } + + fn.write_text("def test(): assert True", encoding="utf-8") + source_stat, hash, co = _rewrite_test(fn, config) + _write_pyc(state, co, source_stat, hash, pyc) assert _read_pyc(fn, pyc, state.trace) is not None + pyc_bytes = pyc.read_bytes() + assert pyc_bytes[4] == 3 # checked-hash flag set + assert pyc_bytes[8:16] == hash + def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: from _pytest.assertion.rewrite import _read_pyc @@ -1435,13 +1460,11 @@ def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: os.utime(source, (mtime_int, mtime_int)) size = len(source_bytes).to_bytes(4, "little") - hash = source_hash(source_bytes) - hash = hash[:8] code = marshal.dumps(compile(source_bytes, str(source), "exec")) # Good header. - pyc.write_bytes(magic + flags + mtime + size + hash + code) + pyc.write_bytes(magic + flags + mtime + size + code) assert _read_pyc(source, pyc, print) is not None # Too short. @@ -1449,21 +1472,65 @@ def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: assert _read_pyc(source, pyc, print) is None # Bad magic. - pyc.write_bytes(b"\x12\x34\x56\x78" + flags + mtime + size + hash + code) + pyc.write_bytes(b"\x12\x34\x56\x78" + flags + mtime + size + code) assert _read_pyc(source, pyc, print) is None # Unsupported flags. - pyc.write_bytes(magic + b"\x00\xff\x00\x00" + mtime + size + hash + code) + pyc.write_bytes(magic + b"\x00\xff\x00\x00" + mtime + size + code) assert _read_pyc(source, pyc, print) is None - # Bad size. - pyc.write_bytes(magic + flags + mtime + b"\x99\x00\x00\x00" + hash + code) + # Bad mtime. + pyc.write_bytes(magic + flags + b"\x58\x3d\xb0\x5f" + size + code) assert _read_pyc(source, pyc, print) is None - # Bad mtime + bad hash. - pyc.write_bytes(magic + flags + b"\x58\x3d\xb0\x5f" + size + b"\x00" * 8 + code) + # Bad size. + pyc.write_bytes(magic + flags + mtime + b"\x99\x00\x00\x00" + code) assert _read_pyc(source, pyc, print) is None + def test_read_pyc_more_invalid_hash(self, tmp_path: Path) -> None: + from _pytest.assertion.rewrite import _read_pyc + + source = tmp_path / "source.py" + pyc = tmp_path / "source.pyc" + + source_bytes = b"def test(): pass\n" + source.write_bytes(source_bytes) + + magic = importlib.util.MAGIC_NUMBER + + flags = b"\x00\x00\x00\x00" + flags_hash = b"\x03\x00\x00\x00" + + mtime = b"\x58\x3c\xb0\x5f" + mtime_int = int.from_bytes(mtime, "little") + os.utime(source, (mtime_int, mtime_int)) + + size = len(source_bytes).to_bytes(4, "little") + + hash = source_hash(source_bytes) + hash = hash[:8] + + code = marshal.dumps(compile(source_bytes, str(source), "exec")) + + # check_hash_based_pycs == "default" with hash based pyc file. + pyc.write_bytes(magic + flags_hash + hash + code) + assert _read_pyc(source, pyc, print) is not None + + # check_hash_based_pycs == "always" with hash based pyc file. + with mock.patch.object(_imp, "check_hash_based_pycs", "always"): + pyc.write_bytes(magic + flags_hash + hash + code) + assert _read_pyc(source, pyc, print) is not None + + # Bad hash. + with mock.patch.object(_imp, "check_hash_based_pycs", "always"): + pyc.write_bytes(magic + flags_hash + b"\x00" * 8 + code) + assert _read_pyc(source, pyc, print) is None + + # check_hash_based_pycs == "always" with timestamp based pyc file. + with mock.patch.object(_imp, "check_hash_based_pycs", "always"): + pyc.write_bytes(magic + flags + mtime + size + code) + assert _read_pyc(source, pyc, print) is None + def test_reload_is_same_and_reloads(self, pytester: Pytester) -> None: """Reloading a (collected) module after change picks up the change.""" pytester.makeini( From cd1a87a9157a58f881e8be0c0bfdad65b3fca1b5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 18 Aug 2026 23:14:06 +0200 Subject: [PATCH 3/3] assertion/rewrite: always use checked-hash pyc invalidation Drop the --invalidation-mode option and write checked-hash pycs unconditionally. The option existed because hashing was assumed to cost enough to want a timestamp fast path. Measured, it does not: reading the source and hashing it costs ~15us per file, against ~1.1ms for a cache hit that has to unmarshal the code object anyway, and ~110ms for the rewrite the cache avoids. With one format there is no reason to consult _imp.check_hash_based_pycs either. That is a private CPython attribute governing how the *builtin* import system treats hash based pycs, and it never sees the pycs written here. The source stat disappears from both paths: _rewrite_test no longer needs it for the header, and _read_pyc reads the source instead of stat'ing it. Also fixes #13292, where an edit within one mtime second of the previous one was invisible to the timestamp comparison. --- changelog/11418.improvement.rst | 12 ++- changelog/13292.bugfix.rst | 3 + src/_pytest/assertion/__init__.py | 1 - src/_pytest/assertion/rewrite.py | 88 +++++------------- src/_pytest/main.py | 8 -- testing/test_assertrewrite.py | 149 +++++++++++++----------------- 6 files changed, 104 insertions(+), 157 deletions(-) create mode 100644 changelog/13292.bugfix.rst diff --git a/changelog/11418.improvement.rst b/changelog/11418.improvement.rst index 1bdb0b60602..6517ba422fd 100644 --- a/changelog/11418.improvement.rst +++ b/changelog/11418.improvement.rst @@ -1 +1,11 @@ -Added hash comparison for pyc cache files. +The cached rewritten modules in ``__pycache__`` are now invalidated by a hash of +the source instead of its modification time, using the checked-hash pyc format +from :pep:`552`. + +Timestamps make the cache useless wherever source files get fresh modification +times, most notably in CI: a checkout gives every file a new mtime, so a +restored cache is discarded in full and every test module is rewritten again. +Hashing the source costs roughly 15 microseconds per file and makes the cache +survive that. + +Existing timestamp based caches are ignored and rewritten once. diff --git a/changelog/13292.bugfix.rst b/changelog/13292.bugfix.rst new file mode 100644 index 00000000000..279541e7661 --- /dev/null +++ b/changelog/13292.bugfix.rst @@ -0,0 +1,3 @@ +Fixed a stale rewritten module being used when a test file is modified twice +within the same second without changing its size -- the pyc header could only +record whole-second timestamps. The cache is now keyed on a hash of the source. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 375d1363a54..a171633f320 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -128,7 +128,6 @@ class AssertionState: def __init__(self, config: Config, mode) -> None: self.mode = mode self.trace = config.trace.root.get("assertion") - self.invalidation_mode = config.known_args_namespace.invalidationmode self.hook: rewrite.AssertionRewritingHook | None = None diff --git a/src/_pytest/assertion/rewrite.py b/src/_pytest/assertion/rewrite.py index 67e6cd306ce..10abbe743af 100644 --- a/src/_pytest/assertion/rewrite.py +++ b/src/_pytest/assertion/rewrite.py @@ -19,12 +19,10 @@ import os from pathlib import Path from pathlib import PurePath -import struct import sys import tokenize import types from typing import IO -from typing import Literal from typing import TYPE_CHECKING @@ -38,8 +36,6 @@ from importlib.resources.readers import FileReader -import _imp - from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE from _pytest._io.saferepr import saferepr from _pytest._io.saferepr import saferepr_unlimited @@ -179,11 +175,11 @@ def exec_module(self, module: types.ModuleType) -> None: co = _read_pyc(fn, pyc, state.trace) if co is None: state.trace(f"rewriting {fn!r}") - source_stat, source_hash, co = _rewrite_test(fn, self.config) + source_hash, co = _rewrite_test(fn, self.config) if write: self._writing_pyc = True try: - _write_pyc(state, co, source_stat, source_hash, pyc) + _write_pyc(state, co, source_hash, pyc) finally: self._writing_pyc = False else: @@ -300,46 +296,28 @@ def get_resource_reader(self, name: str) -> TraversableResources: return FileReader(types.SimpleNamespace(path=self._rewritten_names[name])) # type: ignore[arg-type] -def _write_pyc_fp( - fp: IO[bytes], - source_stat: os.stat_result, - source_hash: bytes, - co: types.CodeType, - invalidation_mode: Literal["timestamp", "checked-hash"], -) -> None: +def _write_pyc_fp(fp: IO[bytes], source_hash: bytes, co: types.CodeType) -> None: # Technically, we don't have to have the same pyc format as # (C)Python, since these "pycs" should never be seen by builtin # import. However, there's little reason to deviate. fp.write(importlib.util.MAGIC_NUMBER) - # https://www.python.org/dev/peps/pep-0552/ - if invalidation_mode == "timestamp": - flags = b"\x00\x00\x00\x00" - fp.write(flags) - # as of now, bytecode header expects 32-bit numbers for size and mtime (#4903) - mtime = int(source_stat.st_mtime) & 0xFFFFFFFF - size = source_stat.st_size & 0xFFFFFFFF - # " bool: proc_pyc = f"{pyc}.{os.getpid()}" try: with open(proc_pyc, "wb") as fp: - _write_pyc_fp(fp, source_stat, source_hash, co, state.invalidation_mode) + _write_pyc_fp(fp, source_hash, co) except OSError as e: state.trace(f"error writing pyc file at {proc_pyc}: errno={e.errno}") return False @@ -355,18 +333,15 @@ def _write_pyc( return True -def _rewrite_test( - fn: Path, config: Config -) -> tuple[os.stat_result, bytes, types.CodeType]: - """Read and rewrite *fn* and return the code object.""" - stat = os.stat(fn) +def _rewrite_test(fn: Path, config: Config) -> tuple[bytes, types.CodeType]: + """Read and rewrite *fn* and return its source hash and code object.""" source = fn.read_bytes() source_hash = importlib.util.source_hash(source) strfn = str(fn) tree = ast.parse(source, filename=strfn) rewrite_asserts(tree, source, strfn, config) co = compile(tree, strfn, "exec", dont_inherit=True) - return stat, source_hash, co + return source_hash, co def _read_pyc( @@ -382,9 +357,6 @@ def _read_pyc( return None with fp: try: - stat_result = os.stat(source) - mtime = int(stat_result.st_mtime) - size = stat_result.st_size data = fp.read(16) except OSError as e: trace(f"_read_pyc({source}): OSError {e}") @@ -396,29 +368,17 @@ def _read_pyc( if data[:4] != importlib.util.MAGIC_NUMBER: trace(f"_read_pyc({source}): invalid pyc (bad magic number)") return None - - hash_based = getattr(_imp, "check_hash_based_pycs", "default") == "always" - if data[4:8] == b"\x00\x00\x00\x00" and not hash_based: - trace(f"_read_pyc({source}): timestamp based") - mtime_data = data[8:12] - if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF: - trace(f"_read_pyc({source}): out of date") - return None - size_data = data[12:16] - if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF: - trace(f"_read_pyc({source}): invalid pyc (incorrect size)") - return None - elif data[4:8] == b"\x03\x00\x00\x00": - trace(f"_read_pyc({source}): hash based") - hash = data[8:16] - source_hash = importlib.util.source_hash(source.read_bytes()) - if source_hash[:8] != hash: - trace(f"_read_pyc({source}): hash doesn't match") - return None - else: + if data[4:8] != b"\x03\x00\x00\x00": trace(f"_read_pyc({source}): invalid pyc (unsupported flags)") return None - + try: + source_hash = importlib.util.source_hash(source.read_bytes()) + except OSError as e: + trace(f"_read_pyc({source}): OSError {e}") + return None + if source_hash[:8] != data[8:16]: + trace(f"_read_pyc({source}): out of date") + return None try: co = marshal.load(fp) except Exception as e: diff --git a/src/_pytest/main.py b/src/_pytest/main.py index e968b0cd719..1b337e20c7e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -222,14 +222,6 @@ def pytest_addoption(parser: Parser) -> None: help="Prepend/append to sys.path when importing test modules and conftest " "files. Default: prepend.", ) - group.addoption( - "--invalidation-mode", - default="timestamp", - choices=["timestamp", "checked-hash"], - dest="invalidationmode", - help="Pytest pyc cache invalidation mode. Default: timestamp.", - ) - parser.addini( "norecursedirs", "Directory patterns to avoid for recursion", diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index a5f12148cc0..12e12449693 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -1,7 +1,6 @@ # mypy: allow-untyped-defs from __future__ import annotations -import _imp import ast from collections.abc import Generator from collections.abc import Mapping @@ -1326,15 +1325,14 @@ def test_write_pyc(self, pytester: Pytester, tmp_path) -> None: config = pytester.parseconfig() state = AssertionState(config, "rewrite") tmp_path.joinpath("source.py").touch() - source_path = str(tmp_path) source_bytes = tmp_path.joinpath("source.py").read_bytes() pycpath = tmp_path.joinpath("pyc") co = compile("1", "f.py", "single") hash = source_hash(source_bytes) - assert _write_pyc(state, co, os.stat(source_path), hash, pycpath) + assert _write_pyc(state, co, hash, pycpath) with mock.patch.object(os, "replace", side_effect=OSError): - assert not _write_pyc(state, co, os.stat(source_path), hash, pycpath) + assert not _write_pyc(state, co, hash, pycpath) def test_resources_provider_for_loader(self, pytester: Pytester) -> None: """ @@ -1407,41 +1405,39 @@ def test_read_pyc_success(self, tmp_path: Path, pytester: Pytester) -> None: fn.write_text("def test(): assert True", encoding="utf-8") - source_stat, hash, co = _rewrite_test(fn, config) - _write_pyc(state, co, source_stat, hash, pyc) + hash, co = _rewrite_test(fn, config) + _write_pyc(state, co, hash, pyc) assert _read_pyc(fn, pyc, state.trace) is not None pyc_bytes = pyc.read_bytes() - assert pyc_bytes[4] == 0 # timestamp flag set + assert pyc_bytes[4] == 3 # checked-hash flag set + assert pyc_bytes[8:16] == hash[:8] + + def test_read_pyc_ignores_mtime(self, tmp_path: Path, pytester: Pytester) -> None: + """A pyc stays valid when only the mtime of the source changes. - def test_read_pyc_success_hash(self, tmp_path: Path, pytester: Pytester) -> None: + This is what makes the cache survive a fresh checkout or a restored + CI cache, where every source file gets a new mtime. + """ from _pytest.assertion import AssertionState from _pytest.assertion.rewrite import _read_pyc from _pytest.assertion.rewrite import _rewrite_test from _pytest.assertion.rewrite import _write_pyc - config = pytester.parseconfig("--invalidation-mode=checked-hash") + config = pytester.parseconfig() state = AssertionState(config, "rewrite") fn = tmp_path / "source.py" pyc = Path(str(fn) + "c") + fn.write_text("def test(): assert True", encoding="utf-8") - # Test private attribute didn't change - assert getattr(_imp, "check_hash_based_pycs", None) in { - "default", - "always", - "never", - } + hash, co = _rewrite_test(fn, config) + _write_pyc(state, co, hash, pyc) - fn.write_text("def test(): assert True", encoding="utf-8") - source_stat, hash, co = _rewrite_test(fn, config) - _write_pyc(state, co, source_stat, hash, pyc) + new_mtime = os.stat(fn).st_mtime + 3600 + os.utime(fn, (new_mtime, new_mtime)) assert _read_pyc(fn, pyc, state.trace) is not None - pyc_bytes = pyc.read_bytes() - assert pyc_bytes[4] == 3 # checked-hash flag set - assert pyc_bytes[8:16] == hash - def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: from _pytest.assertion.rewrite import _read_pyc @@ -1452,84 +1448,71 @@ def test_read_pyc_more_invalid(self, tmp_path: Path) -> None: source.write_bytes(source_bytes) magic = importlib.util.MAGIC_NUMBER - - flags = b"\x00\x00\x00\x00" - - mtime = b"\x58\x3c\xb0\x5f" - mtime_int = int.from_bytes(mtime, "little") - os.utime(source, (mtime_int, mtime_int)) - - size = len(source_bytes).to_bytes(4, "little") - + flags = b"\x03\x00\x00\x00" + hash = source_hash(source_bytes)[:8] code = marshal.dumps(compile(source_bytes, str(source), "exec")) # Good header. - pyc.write_bytes(magic + flags + mtime + size + code) + pyc.write_bytes(magic + flags + hash + code) assert _read_pyc(source, pyc, print) is not None # Too short. - pyc.write_bytes(magic + flags + mtime) + pyc.write_bytes(magic + flags + hash[:4]) assert _read_pyc(source, pyc, print) is None # Bad magic. - pyc.write_bytes(b"\x12\x34\x56\x78" + flags + mtime + size + code) + pyc.write_bytes(b"\x12\x34\x56\x78" + flags + hash + code) assert _read_pyc(source, pyc, print) is None - # Unsupported flags. - pyc.write_bytes(magic + b"\x00\xff\x00\x00" + mtime + size + code) - assert _read_pyc(source, pyc, print) is None + # Unsupported flags -- including the timestamp based pycs written by + # pytest<9.3 and by CPython itself. + for bad_flags in ( + b"\x00\x00\x00\x00", + b"\x01\x00\x00\x00", + b"\x00\xff\x00\x00", + ): + pyc.write_bytes(magic + bad_flags + hash + code) + assert _read_pyc(source, pyc, print) is None - # Bad mtime. - pyc.write_bytes(magic + flags + b"\x58\x3d\xb0\x5f" + size + code) + # Bad hash. + pyc.write_bytes(magic + flags + b"\x00" * 8 + code) assert _read_pyc(source, pyc, print) is None - # Bad size. - pyc.write_bytes(magic + flags + mtime + b"\x99\x00\x00\x00" + code) + # Missing source. + pyc.write_bytes(magic + flags + hash + code) + source.unlink() assert _read_pyc(source, pyc, print) is None - def test_read_pyc_more_invalid_hash(self, tmp_path: Path) -> None: - from _pytest.assertion.rewrite import _read_pyc - - source = tmp_path / "source.py" - pyc = tmp_path / "source.pyc" - - source_bytes = b"def test(): pass\n" - source.write_bytes(source_bytes) - - magic = importlib.util.MAGIC_NUMBER - - flags = b"\x00\x00\x00\x00" - flags_hash = b"\x03\x00\x00\x00" - - mtime = b"\x58\x3c\xb0\x5f" - mtime_int = int.from_bytes(mtime, "little") - os.utime(source, (mtime_int, mtime_int)) - - size = len(source_bytes).to_bytes(4, "little") - - hash = source_hash(source_bytes) - hash = hash[:8] - - code = marshal.dumps(compile(source_bytes, str(source), "exec")) - - # check_hash_based_pycs == "default" with hash based pyc file. - pyc.write_bytes(magic + flags_hash + hash + code) - assert _read_pyc(source, pyc, print) is not None - - # check_hash_based_pycs == "always" with hash based pyc file. - with mock.patch.object(_imp, "check_hash_based_pycs", "always"): - pyc.write_bytes(magic + flags_hash + hash + code) - assert _read_pyc(source, pyc, print) is not None - - # Bad hash. - with mock.patch.object(_imp, "check_hash_based_pycs", "always"): - pyc.write_bytes(magic + flags_hash + b"\x00" * 8 + code) - assert _read_pyc(source, pyc, print) is None + def test_rewrite_picks_up_edit_within_one_mtime_second( + self, pytester: Pytester + ) -> None: + """Regression test for #13292. - # check_hash_based_pycs == "always" with timestamp based pyc file. - with mock.patch.object(_imp, "check_hash_based_pycs", "always"): - pyc.write_bytes(magic + flags + mtime + size + code) - assert _read_pyc(source, pyc, print) is None + The pyc header can only hold a whole-second timestamp, so a file + edited twice within the same second used to be served from a stale + pyc. Hashing the source instead sidesteps the resolution problem. + """ + source = pytester.path / "test_edited.py" + pyc_dir = source.parent / "__pycache__" + + # both revisions are the same size, so only the content differs + before = "def test_aaa(): assert True\n" + after = "def test_bbb(): assert None\n" + assert len(before) == len(after) + + source.write_text(before, encoding="utf-8") + assert pytester.runpytest_subprocess("-q").ret == 0 + (pyc,) = pyc_dir.glob("test_edited.*.pyc") + mtime = os.stat(source).st_mtime + + source.write_text(after, encoding="utf-8") + # pin the mtime so the edit is indistinguishable by timestamp + os.utime(source, (mtime, mtime)) + assert pyc.exists() # the pyc written by the first run is still there + + result = pytester.runpytest_subprocess("-q") + result.stdout.fnmatch_lines(["*test_bbb*"]) + assert result.ret != 0 def test_reload_is_same_and_reloads(self, pytester: Pytester) -> None: """Reloading a (collected) module after change picks up the change."""