From 623abd5265b250bc69df41a59acd227091d15a38 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Mon, 1 Dec 2025 01:45:33 -0500 Subject: [PATCH 1/6] Add failing test --- tests/functional/test_install_wheel.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py index 9ab76ba17b7..38ec1e21d2f 100644 --- a/tests/functional/test_install_wheel.py +++ b/tests/functional/test_install_wheel.py @@ -801,3 +801,15 @@ 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] + + assert int(item.mtime) == 1523973424, "mtime does not match expected value" From 64c11dc9e2e448665b3ec5eaa3875a404a8550b0 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Thu, 4 Dec 2025 00:10:58 -0500 Subject: [PATCH 2/6] Set mtime of installed files --- src/pip/_internal/operations/install/wheel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 2724f150f7b..16558642da5 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 ( @@ -371,6 +373,9 @@ def save(self) -> None: blocksize = min(zipinfo.file_size, 1024 * 1024) shutil.copyfileobj(f, dest, blocksize) + mtime = datetime(*zipinfo.date_time).timestamp() + os.utime(self.dest_path, (mtime, mtime)) + if zip_item_is_executable(zipinfo): set_extracted_file_to_default_mode_plus_executable(self.dest_path) From 957b4c576e23330d234e7d1cb0a316db5b6ade01 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Thu, 4 Dec 2025 00:38:49 -0500 Subject: [PATCH 3/6] Add news fragment --- news/13207.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/13207.bugfix.rst 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. From 4dda8f46a0400716db0d413f76e31365c9882698 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Thu, 4 Dec 2025 21:54:46 -0500 Subject: [PATCH 4/6] Account for local timezone when computing packed mtime --- src/pip/_internal/operations/install/wheel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 16558642da5..53b491a2991 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -373,7 +373,7 @@ def save(self) -> None: blocksize = min(zipinfo.file_size, 1024 * 1024) shutil.copyfileobj(f, dest, blocksize) - mtime = datetime(*zipinfo.date_time).timestamp() + mtime = datetime(*zipinfo.date_time).astimezone().timestamp() os.utime(self.dest_path, (mtime, mtime)) if zip_item_is_executable(zipinfo): From 6dca285e8b216936db68f2e0946c370a5e438d21 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Thu, 4 Dec 2025 21:55:46 -0500 Subject: [PATCH 5/6] Correct test expectation, use datetime for clarity --- tests/functional/test_install_wheel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/functional/test_install_wheel.py b/tests/functional/test_install_wheel.py index 38ec1e21d2f..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 @@ -812,4 +813,9 @@ def test_wheel_install_mtime(script: PipTestEnvironment, data: TestData) -> None pth = script.site_packages / "simplewheel" / "__init__.py" item = result.files_created[pth] - assert int(item.mtime) == 1523973424, "mtime does not match expected value" + # 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" From b2a758e0db613ff4063cb93fc8362eaa27a2647e Mon Sep 17 00:00:00 2001 From: James Gerity Date: Thu, 4 Dec 2025 22:02:12 -0500 Subject: [PATCH 6/6] Move mtime manipulation to separate helper --- src/pip/_internal/operations/install/wheel.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pip/_internal/operations/install/wheel.py b/src/pip/_internal/operations/install/wheel.py index 53b491a2991..ec52e8e6fb0 100644 --- a/src/pip/_internal/operations/install/wheel.py +++ b/src/pip/_internal/operations/install/wheel.py @@ -339,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 @@ -373,8 +380,7 @@ def save(self) -> None: blocksize = min(zipinfo.file_size, 1024 * 1024) shutil.copyfileobj(f, dest, blocksize) - mtime = datetime(*zipinfo.date_time).astimezone().timestamp() - os.utime(self.dest_path, (mtime, mtime)) + _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)