Skip to content

Commit 5ce88cb

Browse files
committed
Allow unpinned requirements to use hashes from constraints
Constraints with `==version --hash=...` correctly narrow the candidate set, but the per-candidate `InstallRequirement` still reflected the original unpinned and hashless requirement. In such a configuration, plumb through the hashes from the constraints to the per-candidate `InstallRequirement`, and pin it to the version from the link (which is informed by the constraint). This makes hashes get correctly used for candidate selection and verification, at the cost of a few copies at the start of the resolve in such cases.
1 parent 8c5468d commit 5ce88cb

File tree

4 files changed

+75
-6
lines changed

4 files changed

+75
-6
lines changed

src/pip/_internal/resolution/resolvelib/base.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,23 @@ def format_name(project: NormalizedName, extras: frozenset[NormalizedName]) -> s
2626
class Constraint:
2727
specifier: SpecifierSet
2828
hashes: Hashes
29+
hash_options: dict[str, list[str]]
2930
links: frozenset[Link]
3031

3132
@classmethod
3233
def empty(cls) -> Constraint:
33-
return Constraint(SpecifierSet(), Hashes(), frozenset())
34+
return Constraint(SpecifierSet(), Hashes(), {}, frozenset())
3435

3536
@classmethod
3637
def from_ireq(cls, ireq: InstallRequirement) -> Constraint:
3738
links = frozenset([ireq.link]) if ireq.link else frozenset()
38-
return Constraint(ireq.specifier, ireq.hashes(trust_internet=False), links)
39+
hash_options = {alg: list(v) for alg, v in ireq.hash_options.items()}
40+
return Constraint(
41+
ireq.specifier,
42+
ireq.hashes(trust_internet=False),
43+
hash_options,
44+
links,
45+
)
3946

4047
def __bool__(self) -> bool:
4148
return bool(self.specifier) or bool(self.hashes) or bool(self.links)
@@ -45,10 +52,19 @@ def __and__(self, other: InstallRequirement) -> Constraint:
4552
return NotImplemented
4653
specifier = self.specifier & other.specifier
4754
hashes = self.hashes & other.hashes(trust_internet=False)
55+
if not self.hash_options:
56+
hash_options = {alg: list(v) for alg, v in other.hash_options.items()}
57+
elif not other.hash_options:
58+
hash_options = {alg: list(v) for alg, v in self.hash_options.items()}
59+
else:
60+
hash_options = {
61+
alg: [v for v in other.hash_options[alg] if v in self.hash_options[alg]]
62+
for alg in self.hash_options.keys() & other.hash_options.keys()
63+
}
4864
links = self.links
4965
if other.link:
5066
links = links.union([other.link])
51-
return Constraint(specifier, hashes, links)
67+
return Constraint(specifier, hashes, hash_options, links)
5268

5369
def is_satisfied_by(self, candidate: Candidate) -> bool:
5470
# Reject if there are any mismatched URL constraints on this package.

src/pip/_internal/resolution/resolvelib/candidates.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,14 @@ def as_base_candidate(candidate: Candidate) -> BaseCandidate | None:
5858

5959

6060
def make_install_req_from_link(
61-
link: Link, template: InstallRequirement
61+
link: Link,
62+
template: InstallRequirement,
63+
version: Version | None = None,
6264
) -> InstallRequirement:
6365
assert not template.editable, "template is editable"
64-
if template.req:
66+
if version is not None and template.req:
67+
line = f"{template.req.name}=={version}"
68+
elif template.req:
6569
line = str(template.req)
6670
else:
6771
line = link.url
@@ -288,7 +292,7 @@ def __init__(
288292
if cache_entry is not None:
289293
logger.debug("Using cached wheel link: %s", cache_entry.link)
290294
link = cache_entry.link
291-
ireq = make_install_req_from_link(link, template)
295+
ireq = make_install_req_from_link(link, template, version=version)
292296
assert ireq.link == link
293297
if ireq.link.is_wheel and not ireq.link.is_file:
294298
wheel = Wheel(ireq.link.filename)

src/pip/_internal/resolution/resolvelib/factory.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import copy
45
import functools
56
import logging
67
from collections.abc import Iterable, Iterator, Mapping, Sequence
@@ -248,6 +249,7 @@ def _iter_found_candidates(
248249
hashes: Hashes,
249250
prefers_installed: bool,
250251
incompatible_ids: set[int],
252+
constraint_hash_options: dict[str, list[str]] | None = None,
251253
) -> Iterable[Candidate]:
252254
if not ireqs:
253255
return ()
@@ -258,6 +260,15 @@ def _iter_found_candidates(
258260
# Hopefully the Project model can correct this mismatch in the future.
259261
template = ireqs[0]
260262
assert template.req, "Candidates found on index must be PEP 508"
263+
if (
264+
constraint_hash_options
265+
and not template.hash_options
266+
and any(constraint_hash_options.values())
267+
):
268+
template = copy.copy(template)
269+
template.hash_options = {
270+
k: list(v) for k, v in constraint_hash_options.items()
271+
}
261272
name = canonicalize_name(template.req.name)
262273

263274
extras: frozenset[str] = frozenset()
@@ -453,6 +464,7 @@ def find_candidates(
453464
constraint.hashes,
454465
prefers_installed,
455466
incompat_ids,
467+
constraint.hash_options,
456468
)
457469

458470
return (

tests/functional/test_new_resolver_hashes.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,43 @@ def test_new_resolver_hash_requirement_and_url_constraint_can_fail(
292292
script.assert_not_installed("base", "other")
293293

294294

295+
def test_new_resolver_unpinned_requirement_with_pinned_hash_constraint(
296+
script: PipTestEnvironment,
297+
) -> None:
298+
"""Regression test for https://github.com/pypa/pip/issues/9243.
299+
300+
An unpinned requirement combined with a constraints file that supplies both an
301+
``==`` pin and ``--hash`` for that distribution used to fail with ``HashUnpinned``:
302+
303+
> In --require-hashes mode, all requirements must have their versions pinned with ==
304+
305+
This was because "is_pinned" could not be true for the unpinned requirement, even
306+
though the constraint did have a pin that was being enforced.
307+
"""
308+
find_links = _create_find_links(script)
309+
310+
requirements_txt = script.scratch_path / "requirements.txt"
311+
requirements_txt.write_text("base\n")
312+
313+
constraints_txt = script.scratch_path / "constraints.txt"
314+
constraints_txt.write_text(f"base==0.1.0 --hash=sha256:{find_links.wheel_hash}\n")
315+
316+
script.pip(
317+
"install",
318+
"--no-cache-dir",
319+
"--no-deps",
320+
"--no-index",
321+
"--find-links",
322+
find_links.index_html,
323+
"--constraint",
324+
constraints_txt,
325+
"--requirement",
326+
requirements_txt,
327+
)
328+
329+
script.assert_installed(base="0.1.0")
330+
331+
295332
def test_new_resolver_hash_with_extras(script: PipTestEnvironment) -> None:
296333
parent_with_extra_path = create_basic_wheel_for_package(
297334
script, "parent_with_extra", "0.1.0", depends=["child[extra]"]

0 commit comments

Comments
 (0)