diff --git a/news/13207.bugfix.rst b/news/13207.bugfix.rst new file mode 100644 index 00000000000..8f53a4102fe --- /dev/null +++ b/news/13207.bugfix.rst @@ -0,0 +1 @@ +Preserve ``mtime`` of files installed from wheels. diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 2724f150f7b..ec52e8e6fb0 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -8,6 +8,7 @@ import csv import importlib import logging +import os import os.path import re import shutil @@ -16,6 +17,7 @@ import warnings from base64 import urlsafe_b64encode from collections.abc import Generator, Iterable, Iterator, Sequence +from datetime import datetime from email.message import Message from itertools import chain, filterfalse, starmap from typing import ( @@ -337,6 +339,13 @@ def get_console_script_specs(console: dict[str, str]) -> list[str]: return scripts_to_generate +def _set_extracted_file_mtime(extracted_file: str, zipinfo: ZipInfo) -> None: + # ZipInfo.date_time is interpreted as a local time, so we must account for + # the local timezone when computing the target timestamp + mtime = datetime(*zipinfo.date_time).astimezone().timestamp() + os.utime(extracted_file, (mtime, mtime)) + + class ZipBackedFile: def __init__( self, src_record_path: RecordPath, dest_path: str, zip_file: ZipFile @@ -371,6 +380,8 @@ def save(self) -> None: blocksize = min(zipinfo.file_size, 1024 * 1024) shutil.copyfileobj(f, dest, blocksize) + _set_extracted_file_mtime(self.dest_path, zipinfo) + if zip_item_is_executable(zipinfo): set_extracted_file_to_default_mode_plus_executable(self.dest_path) diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py index 9ab76ba17b7..0859e3b0409 100644 --- a/tests/functional/test_install_wheel.py +++ b/tests/functional/test_install_wheel.py @@ -4,6 +4,7 @@ import os import shutil import sysconfig +from datetime import datetime from pathlib import Path from typing import Any @@ -801,3 +802,20 @@ def test_wheel_with_unknown_subdir_in_data_dir_has_reasonable_error( result = script.pip("install", "--no-index", str(wheel_path), expect_error=True) assert "simple-0.1.0.data/unknown/hello.txt" in result.stderr + + +def test_wheel_install_mtime(script: PipTestEnvironment, data: TestData) -> None: + """Check that installed file mtime matches value inside the given wheel""" + to_install = data.packages.joinpath("simplewheel-1.0-py2.py3-none-any.whl") + result = script.pip("install", to_install) + result.assert_installed("simplewheel", editable=False) + + pth = script.site_packages / "simplewheel" / "__init__.py" + item = result.files_created[pth] + + # These are both naive, so it is reasonable to compare them, and a bit more + # legible than comparing to a timestamp expectation + dt = datetime.fromtimestamp(item.mtime) + expected_dt = datetime(2018, 4, 17, 9, 57, 4) + + assert dt == expected_dt, "Extracted file mtime does not match expected value"