perf: avoid redundant hash verification for cached wheel files - #13860
perf: avoid redundant hash verification for cached wheel files#13860abhiyadav2345 wants to merge 2 commits into
Conversation
When a wheel is found in the download directory by _check_download_dir and its hash is verified there, _prepare_linked_requirement was unconditionally calling hashes.check_against_path() a second time on the same file in its else-branch. For large wheels (e.g. GB-sized ML packages) this means reading the entire file from disk twice. Fix by adding self._hash_verified: set[str] to RequirementPreparer to track URLs whose files have already passed hash verification. When _check_download_dir returns a path and hashes were provided, the URL is recorded as verified. _prepare_linked_requirement then skips the redundant check_against_path call for those URLs. The same tracking is applied in prepare_linked_requirements_more, which shares the same _check_download_dir -> _prepare_linked_requirement flow. Fixes pypa#12589
|
Thanks for the PR, please be aware review capacity is very low for pip right now, but at a first glance this PR looks quite promising. Please let us know if you need any help passing linting: https://pip.pypa.io/en/latest/development/getting-started/#running-linters |
| if file_path is not None: | ||
| self._downloaded[req.link.url] = file_path | ||
| if hashes: | ||
| self._hash_verified.add(req.link.url) |
There was a problem hiding this comment.
Do these lines always appear together? I wonder if in self._hash_verified would simply be equivalent to in self._downloaded.
There was a problem hiding this comment.
Good question — they're not equivalent, and the distinction matters for correctness.
_downloaded is populated in three places:
- Line 491 — after a fresh batch download (
_complete_partial_requirements). The file was just fetched from the network;_check_download_dirwas never called, so no hash has been verified yet. - Lines 532/565 — when
_check_download_dirfinds the wheel already in the download cache. Only here is the hash verified upfront. - In case 2,
_hash_verifiedis also populated — but only whenhashesis non-empty.
At line 643, the else branch is reached for all URLs in _downloaded — including those from case 1 (fresh download). If we replaced link.url not in self._hash_verified with link.url not in self._downloaded, we'd skip check_against_path for freshly-downloaded files too, silently bypassing hash verification — a security regression.
_hash_verified is intentionally narrower: it only tracks URLs where _check_download_dir already did the work, so the skip at line 643 is safe to apply.
sepehr-rs
left a comment
There was a problem hiding this comment.
Hi @abhiyadav2345, thanks for the contribution!
Overall the PR looks good, but there are a few points worth addressing and a couple of design details to reconsider before it’s ready to be merged.
|
|
||
| # Simulate _prepare_linked_requirement's else-branch: | ||
| # it should skip check_against_path when the URL is already verified. | ||
| if hashes and link.url not in verified_urls: |
There was a problem hiding this comment.
Why are we duplicating _prepare_linked_requirement's else branch here? This test wouldn’t fail if the _hash_verified optimization were removed. It might be stronger to assert call count through the actual RequirementPreparer flow instead.
| def test_returns_none_when_file_absent(self, tmpdir: Path) -> None: | ||
| link = Link("https://example.com/pkg-1.0.whl") | ||
| result = _check_download_dir(link, os.fspath(tmpdir), hashes=None) | ||
| assert result is None | ||
|
|
||
| def test_returns_path_when_file_present_no_hashes(self, tmpdir: Path) -> None: | ||
| content = b"wheel content" | ||
| self._make_file(tmpdir, "pkg-1.0.whl", content) | ||
| link = Link("https://example.com/pkg-1.0.whl") | ||
| result = _check_download_dir(link, os.fspath(tmpdir), hashes=None) | ||
| assert result == os.fspath(tmpdir / "pkg-1.0.whl") | ||
|
|
||
| def test_returns_path_and_verifies_hash_when_file_present( | ||
| self, tmpdir: Path | ||
| ) -> None: | ||
| content = b"wheel content" | ||
| self._make_file(tmpdir, "pkg-1.0.whl", content) | ||
| link = Link("https://example.com/pkg-1.0.whl") | ||
| hashes = self._sha256_hashes(content) | ||
|
|
||
| with patch.object( | ||
| hashes, "check_against_path", wraps=hashes.check_against_path | ||
| ) as mock_check: | ||
| result = _check_download_dir(link, os.fspath(tmpdir), hashes=hashes) | ||
|
|
||
| assert result == os.fspath(tmpdir / "pkg-1.0.whl") | ||
| mock_check.assert_called_once() | ||
|
|
||
| def test_deletes_file_and_returns_none_on_hash_mismatch(self, tmpdir: Path) -> None: | ||
| content = b"wheel content" | ||
| self._make_file(tmpdir, "pkg-1.0.whl", content) | ||
| link = Link("https://example.com/pkg-1.0.whl") | ||
| bad_hashes = Hashes({"sha256": ["deadbeef" * 8]}) | ||
|
|
||
| result = _check_download_dir(link, os.fspath(tmpdir), hashes=bad_hashes) | ||
|
|
||
| assert result is None | ||
| assert not (tmpdir / "pkg-1.0.whl").exists() |
There was a problem hiding this comment.
Why are we testing _check_download_dir here? It wasn't changed in this PR. I’d lean toward keeping this PR focused on the regression in RequirementPreparer, unless there’s intent to expand coverage here as part of this change.
| # URLs whose cached files have already passed hash verification in | ||
| # _check_download_dir, so _prepare_linked_requirement can skip the | ||
| # redundant second check_against_path call for those files. | ||
| self._hash_verified: set[str] = set() |
There was a problem hiding this comment.
Small nitpick here: This reads a bit like a boolean flag, even though it tracks a set of URLs; maybe _verified_hash_urls (or similar) would reflect that more clearly.
Summary
Fixes #12589 —
RequirementPreparerwas hashing large wheel files twice when they were already present in the download directory cache.Root Cause
When
prepare_linked_requirementfinds a wheel indownload_dir, it calls_check_download_dir, which callshashes.check_against_path()— reading and hashing the entire file (pass 1). The path is added toself._downloaded.Then
_prepare_linked_requirementis called. In itselsebranch (the "already downloaded" path), it callshashes.check_against_path()again on the same file — a full second read (pass 2).For multi-GB wheels (common in ML/data science), this means pip reads the entire file from disk twice just for hash verification.
The same double-hash occurs in the
prepare_linked_requirements_more→_prepare_linked_requirementflow.Fix
Add
self._hash_verified: set[str]toRequirementPreparerto track URLs whose files have already passed hash verification in_check_download_dir.prepare_linked_requirementandprepare_linked_requirements_more: when_check_download_dirreturns a path andhashesis non-empty, record the URL as verified._prepare_linked_requirement'selsebranch: skiphashes.check_against_path()when the URL is already inself._hash_verified.Changes
src/pip/_internal/operations/prepare.py— add_hash_verifiedset; populate it after_check_download_dirpasses; skip redundant check in_prepare_linked_requirementtests/unit/test_operations_prepare.py— addTestCheckDownloadDirwith 5 tests covering the normal paths and the regression casenews/12589.bugfix.rst— changelog entryTesting