Visitar URL original
Preserve file and directory mtimes when installing wheels by shashb27 · Pull Request #14330 · pypa/pip · GitHub
Skip to content

Preserve file and directory mtimes when installing wheels - #14330

Open
shashb27 wants to merge 3 commits into
pypa:mainfrom
shashb27:preserve-wheel-install-mtimes
Open

shashb27 wants to merge 3 commits into
pypa:mainfrom
shashb27:preserve-wheel-install-mtimes

Conversation

@shashb27

@shashb27 shashb27 commented Sep 25, 2026 •

Copy link
Copy Markdown

Building on SnoopJ's #13681 (closed with "feel free to open a new PR with comments addressed, happy to review"), this preserves the mtimes of files installed from wheels and — the part #13681 left open — gives the distribution's directories deterministic mtimes so reinstalling an unchanged wheel leaves every timestamp identical. Resolves #13207.

File mtimes

As in #13681: at extraction time, each file's atime and mtime are set from its ZipInfo.date_time (setting both was confirmed fine in the earlier review). The conversion attaches the local timezone before computing the POSIX timestamp, since the zip format stores local time without timezone information — the macOS CI failure in #13681 (a 4-hour offset) came from ignoring that.

Directory mtimes (new — the main review feedback)

Wheels contain no directory entries, so every directory is created by pip, and writing into a directory updates its mtime. Without handling, directory mtimes merely reflect whenever pip last ran — churning on every reinstall even when contents are unchanged, and varying with extraction order.

After all of the distribution's files have been written (including generated ones: RECORD, INSTALLER, __pycache__, rewritten scripts — these all bump parent mtimes, which is exactly the "accidentally updated" problem raised in review), the directories that received wheel files are walked deepest-first and each is assigned the maximum mtime among the wheel-provided items it contains. Generated files are deliberately not counted, so a directory's mtime is a pure function of the wheel's content: reinstalling the same wheel reproduces identical directory mtimes.

Two deliberate choices, flagging for review:

  • Directories that already existed before the install (the reinstall case) are also assigned, not just newly created ones — otherwise the second install of the same wheel would produce different directory mtimes than the first, defeating the point.
  • The scheme directories themselves (site-packages, Scripts, ...) are never touched, since they can be shared with other distributions.
  • Files pip itself modifies after extraction — console scripts with rewritten shebangs — keep their current-time mtime, since pip did in fact change their content.

Performance

Median of 5 runs installing a synthetic 3000-file wheel, Windows/Python 3.13:

Path main this PR delta
default (with pyc compilation) 16.4 s 17.0 s +3.8%
--no-compile (extraction only) 2.7 s 3.1 s +13.5% (~0.12 ms/file)

The cost is one os.utime per extracted file plus the directory pass; it is inherent to setting the timestamps at all. No extra stat passes are done — mtimes are collected during extraction.

Tests

  • test_wheel_install_mtime (from Preserve mtime of files installed from wheels #13681, restored): installed file mtime equals the wheel's stored time; naive-vs-naive comparison, timezone-robust.
  • test_wheel_install_directory_mtimes_stable_across_reinstalls (new): installs the same wheel twice; asserts both installs produce identical directory mtimes, equal to the max mtime of the wheel-provided files inside, and that the site-packages root is not modified.
  • Full tests/functional/test_install_wheel.py (42 passed), tests/functional/test_uninstall.py (27 passed, 8 pre-existing skips), and the wheel-related unit test modules (96 passed) on Windows/Python 3.13. pre-commit clean on touched files.

News entry: news/13207.feature.rst.

Credit: the file-mtime approach and first test are SnoopJ's work from #13681, carried over with the timezone fix they identified.

PR Checklist:

  • I agree to follow the PSF Code of Conduct.
  • I have read and have followed the CONTRIBUTING.md file.
  • I have added a news file fragment (or this PR does not need one).
  • I have read and followed the AI_POLICY.md file, and if any AI tools were used, I have disclosed it below.
  • Assisted-by: opencode (GLM) coding agent

scripttest's change detection compares directories by mtime only, and
FoundFile comparisons include mtime alongside content. With pip now
preserving file and directory mtimes from wheel metadata, a reinstall
of the same wheel is bit-identical, and even upgrades can leave a
package directory untouched when the two wheels' files are identical
(test wheels are often built within the same second, and the simple
test sdists share committed source mtimes).

Assert reinstalls and upgrades through pip's output and the newly
created dist-info directory instead.
@shashb27

Copy link
Copy Markdown
Author

Following up on this one — CI is green across the matrix and the branch is mergeable. The #13681 thread ended with "happy to review" once the comments were addressed, so I wanted to make sure this didn't fall off the radar. Happy to make changes if anything looks off.

@agammann agammann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

dist_dirs only contains the immediate parent of each extracted file. For a wheel with pkg/sub/mod.py but no file directly in pkg/, this resets pkg/sub but never includes pkg, so the package directory keeps the install-time mtime and changes on each reinstall. Could you include ancestor directories up to the scheme root and add this layout to the reinstall test?

@shashb27

shashb27 commented Nov 9, 2026

Copy link
Copy Markdown
Author

Good catch — dist_dirs only had the immediate parent of each extracted file, so a package with nothing directly inside it kept an install-time mtime. I now collect each file's ancestor directories up to (but not including) the scheme root, so pkg gets the max wheel mtime of everything it contains at any depth, and site-packages itself is still never touched. Adding your layout to the reinstall test also exposed a second churn source: on reinstalls the uninstaller stashed pkg/sub inside pkg (pip stashes removed directories next to their original location), and cleaning that stash up after the install bumped pkg's mtime again, so I made compress_for_rename consider ancestor directories up to the dist's location too — with that, a fully-removed package directory is moved out in one piece instead. The reinstall test now covers mtimepkg2/sub/mod.py with no file directly in mtimepkg2/ and checks both directory levels stay stable across --force-reinstall. Pushed as 9269af2.

@agammann

Copy link
Copy Markdown

Thanks for adding the ancestor coverage. One case to consider in the new directory pass: directories below a scheme root can also be shared by multiple distributions, for example an implicit namespace package shared_ns/ containing modules from two wheels. Installing either wheel will reset that shared directory's mtime using only that wheel's timestamps, potentially moving it backwards and hiding the other distribution's directory change from mtime-based watchers. Could this be tested with two wheels sharing a namespace, and the intended behavior for shared directories clarified? This is based on static review of head 9269af2; I did not run the tests locally. AI-assisted review.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pip updates mtimes when insatlling wheels

2 participants