Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions news/12018.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Allow unpinned requirements to use hashes from constraints. Constraints
like ``{name}=={version} --hash=...`` feeds into hash verification for
a corresponding requirement.
22 changes: 19 additions & 3 deletions src/pip/_internal/resolution/resolvelib/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,23 @@ def format_name(project: NormalizedName, extras: frozenset[NormalizedName]) -> s
class Constraint:
specifier: SpecifierSet
hashes: Hashes
hash_options: dict[str, list[str]]
links: frozenset[Link]

@classmethod
def empty(cls) -> Constraint:
return Constraint(SpecifierSet(), Hashes(), frozenset())
return Constraint(SpecifierSet(), Hashes(), {}, frozenset())

@classmethod
def from_ireq(cls, ireq: InstallRequirement) -> Constraint:
links = frozenset([ireq.link]) if ireq.link else frozenset()
return Constraint(ireq.specifier, ireq.hashes(trust_internet=False), links)
hash_options = {alg: list(v) for alg, v in ireq.hash_options.items()}
return Constraint(
ireq.specifier,
ireq.hashes(trust_internet=False),
hash_options,
links,
)

def __bool__(self) -> bool:
return bool(self.specifier) or bool(self.hashes) or bool(self.links)
Expand All @@ -45,10 +52,19 @@ def __and__(self, other: InstallRequirement) -> Constraint:
return NotImplemented
specifier = self.specifier & other.specifier
hashes = self.hashes & other.hashes(trust_internet=False)
if not self.hash_options:
hash_options = {alg: list(v) for alg, v in other.hash_options.items()}
elif not other.hash_options:
hash_options = {alg: list(v) for alg, v in self.hash_options.items()}
else:
hash_options = {
alg: [v for v in other.hash_options[alg] if v in self.hash_options[alg]]
for alg in self.hash_options.keys() & other.hash_options.keys()
}
Comment on lines +60 to +63
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should multiple entries for the same requirement get unioned or intersected?

Right now, this mirrors Hashes.__and__: uses as-is when one side is empty, intersection of them when both are present.

links = self.links
if other.link:
links = links.union([other.link])
return Constraint(specifier, hashes, links)
return Constraint(specifier, hashes, hash_options, links)

def is_satisfied_by(self, candidate: Candidate) -> bool:
# Reject if there are any mismatched URL constraints on this package.
Expand Down
13 changes: 10 additions & 3 deletions src/pip/_internal/resolution/resolvelib/candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@ def as_base_candidate(candidate: Candidate) -> BaseCandidate | None:


def make_install_req_from_link(
link: Link, template: InstallRequirement
link: Link,
template: InstallRequirement,
version: Version | None = None,
) -> InstallRequirement:
assert not template.editable, "template is editable"
if template.req:
if version is not None and template.req and template.hash_options:
# When hashes are provided via constraints for an unpinned requirement,
# the resulting install requirement must appear pinned so that the
# hash-checking logic does not reject it as HashUnpinned.
line = f"{template.req.name}=={version}"
elif template.req:
line = str(template.req)
else:
line = link.url
Expand Down Expand Up @@ -288,7 +295,7 @@ def __init__(
if cache_entry is not None:
logger.debug("Using cached wheel link: %s", cache_entry.link)
link = cache_entry.link
ireq = make_install_req_from_link(link, template)
ireq = make_install_req_from_link(link, template, version=version)
assert ireq.link == link
if ireq.link.is_wheel and not ireq.link.is_file:
wheel = Wheel(ireq.link.filename)
Expand Down
13 changes: 13 additions & 0 deletions src/pip/_internal/resolution/resolvelib/factory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import contextlib
import copy
import functools
import logging
from collections.abc import Iterable, Iterator, Mapping, Sequence
Expand Down Expand Up @@ -248,6 +249,7 @@ def _iter_found_candidates(
hashes: Hashes,
prefers_installed: bool,
incompatible_ids: set[int],
constraint_hash_options: dict[str, list[str]] | None = None,
) -> Iterable[Candidate]:
if not ireqs:
return ()
Expand All @@ -258,6 +260,16 @@ def _iter_found_candidates(
# Hopefully the Project model can correct this mismatch in the future.
template = ireqs[0]
assert template.req, "Candidates found on index must be PEP 508"
if (
constraint_hash_options
and not template.hash_options
and any(constraint_hash_options.values())
):
template = copy.copy(template)
template.hash_options = {
k: list(v) for k, v in constraint_hash_options.items()
}
assert template.req # to prevent mypy from being confused by the copy
name = canonicalize_name(template.req.name)

extras: frozenset[str] = frozenset()
Expand Down Expand Up @@ -453,6 +465,7 @@ def find_candidates(
constraint.hashes,
prefers_installed,
incompat_ids,
constraint.hash_options,
)

return (
Expand Down
37 changes: 37 additions & 0 deletions tests/functional/test_new_resolver_hashes.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,43 @@ def test_new_resolver_hash_requirement_and_url_constraint_can_fail(
script.assert_not_installed("base", "other")


def test_new_resolver_unpinned_requirement_with_pinned_hash_constraint(
script: PipTestEnvironment,
) -> None:
"""Regression test for https://github.com/pypa/pip/issues/9243.

An unpinned requirement combined with a constraints file that supplies both an
``==`` pin and ``--hash`` for that distribution used to fail with ``HashUnpinned``:

> In --require-hashes mode, all requirements must have their versions pinned with ==

This was because "is_pinned" could not be true for the unpinned requirement, even
though the constraint did have a pin that was being enforced.
"""
find_links = _create_find_links(script)

requirements_txt = script.scratch_path / "requirements.txt"
requirements_txt.write_text("base\n")

constraints_txt = script.scratch_path / "constraints.txt"
constraints_txt.write_text(f"base==0.1.0 --hash=sha256:{find_links.wheel_hash}\n")

script.pip(
"install",
"--no-cache-dir",
"--no-deps",
"--no-index",
"--find-links",
find_links.index_html,
"--constraint",
constraints_txt,
"--requirement",
requirements_txt,
)

script.assert_installed(base="0.1.0")


def test_new_resolver_hash_with_extras(script: PipTestEnvironment) -> None:
parent_with_extra_path = create_basic_wheel_for_package(
script, "parent_with_extra", "0.1.0", depends=["child[extra]"]
Expand Down