Skip to content

Commit b551c09

Browse files
authored
Merge pull request #12095 from sanderr/issue/11924-requirements-on-extras
2 parents dfaac0a + 0f543e3 commit b551c09

File tree

13 files changed

+471
-69
lines changed

13 files changed

+471
-69
lines changed

news/11924.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Include all requested extras in the install report (``--report``).

news/11924.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve extras resolution for multiple constraints on same base package.

news/12095.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Consistently report whether a dependency comes from an extra.

src/pip/_internal/req/constructors.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
InstallRequirement.
99
"""
1010

11+
import copy
1112
import logging
1213
import os
1314
import re
14-
from typing import Dict, List, Optional, Set, Tuple, Union
15+
from typing import Collection, Dict, List, Optional, Set, Tuple, Union
1516

1617
from pip._vendor.packaging.markers import Marker
1718
from pip._vendor.packaging.requirements import InvalidRequirement, Requirement
@@ -57,6 +58,31 @@ def convert_extras(extras: Optional[str]) -> Set[str]:
5758
return get_requirement("placeholder" + extras.lower()).extras
5859

5960

61+
def _set_requirement_extras(req: Requirement, new_extras: Set[str]) -> Requirement:
62+
"""
63+
Returns a new requirement based on the given one, with the supplied extras. If the
64+
given requirement already has extras those are replaced (or dropped if no new extras
65+
are given).
66+
"""
67+
match: Optional[re.Match[str]] = re.fullmatch(
68+
# see https://peps.python.org/pep-0508/#complete-grammar
69+
r"([\w\t .-]+)(\[[^\]]*\])?(.*)",
70+
str(req),
71+
flags=re.ASCII,
72+
)
73+
# ireq.req is a valid requirement so the regex should always match
74+
assert (
75+
match is not None
76+
), f"regex match on requirement {req} failed, this should never happen"
77+
pre: Optional[str] = match.group(1)
78+
post: Optional[str] = match.group(3)
79+
assert (
80+
pre is not None and post is not None
81+
), f"regex group selection for requirement {req} failed, this should never happen"
82+
extras: str = "[%s]" % ",".join(sorted(new_extras)) if new_extras else ""
83+
return Requirement(f"{pre}{extras}{post}")
84+
85+
6086
def parse_editable(editable_req: str) -> Tuple[Optional[str], str, Set[str]]:
6187
"""Parses an editable requirement into:
6288
- a requirement name
@@ -504,3 +530,47 @@ def install_req_from_link_and_ireq(
504530
config_settings=ireq.config_settings,
505531
user_supplied=ireq.user_supplied,
506532
)
533+
534+
535+
def install_req_drop_extras(ireq: InstallRequirement) -> InstallRequirement:
536+
"""
537+
Creates a new InstallationRequirement using the given template but without
538+
any extras. Sets the original requirement as the new one's parent
539+
(comes_from).
540+
"""
541+
return InstallRequirement(
542+
req=(
543+
_set_requirement_extras(ireq.req, set()) if ireq.req is not None else None
544+
),
545+
comes_from=ireq,
546+
editable=ireq.editable,
547+
link=ireq.link,
548+
markers=ireq.markers,
549+
use_pep517=ireq.use_pep517,
550+
isolated=ireq.isolated,
551+
global_options=ireq.global_options,
552+
hash_options=ireq.hash_options,
553+
constraint=ireq.constraint,
554+
extras=[],
555+
config_settings=ireq.config_settings,
556+
user_supplied=ireq.user_supplied,
557+
permit_editable_wheels=ireq.permit_editable_wheels,
558+
)
559+
560+
561+
def install_req_extend_extras(
562+
ireq: InstallRequirement,
563+
extras: Collection[str],
564+
) -> InstallRequirement:
565+
"""
566+
Returns a copy of an installation requirement with some additional extras.
567+
Makes a shallow copy of the ireq object.
568+
"""
569+
result = copy.copy(ireq)
570+
result.extras = {*ireq.extras, *extras}
571+
result.req = (
572+
_set_requirement_extras(ireq.req, result.extras)
573+
if ireq.req is not None
574+
else None
575+
)
576+
return result

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def _prepare(self) -> BaseDistribution:
240240
def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requirement]]:
241241
requires = self.dist.iter_dependencies() if with_requires else ()
242242
for r in requires:
243-
yield self._factory.make_requirement_from_spec(str(r), self._ireq)
243+
yield from self._factory.make_requirements_from_spec(str(r), self._ireq)
244244
yield self._factory.make_requires_python_requirement(self.dist.requires_python)
245245

246246
def get_install_requirement(self) -> Optional[InstallRequirement]:
@@ -392,7 +392,7 @@ def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requiremen
392392
if not with_requires:
393393
return
394394
for r in self.dist.iter_dependencies():
395-
yield self._factory.make_requirement_from_spec(str(r), self._ireq)
395+
yield from self._factory.make_requirements_from_spec(str(r), self._ireq)
396396

397397
def get_install_requirement(self) -> Optional[InstallRequirement]:
398398
return None
@@ -427,7 +427,17 @@ def __init__(
427427
self,
428428
base: BaseCandidate,
429429
extras: FrozenSet[str],
430+
*,
431+
comes_from: Optional[InstallRequirement] = None,
430432
) -> None:
433+
"""
434+
:param comes_from: the InstallRequirement that led to this candidate if it
435+
differs from the base's InstallRequirement. This will often be the
436+
case in the sense that this candidate's requirement has the extras
437+
while the base's does not. Unlike the InstallRequirement backed
438+
candidates, this requirement is used solely for reporting purposes,
439+
it does not do any leg work.
440+
"""
431441
self.base = base
432442
self.extras = frozenset(canonicalize_name(e) for e in extras)
433443
# If any extras are requested in their non-normalized forms, keep track
@@ -438,6 +448,7 @@ def __init__(
438448
# TODO: Remove this attribute when packaging is upgraded to support the
439449
# marker comparison logic specified in PEP 685.
440450
self._unnormalized_extras = extras.difference(self.extras)
451+
self._comes_from = comes_from if comes_from is not None else self.base._ireq
441452

442453
def __str__(self) -> str:
443454
name, rest = str(self.base).split(" ", 1)
@@ -543,11 +554,11 @@ def iter_dependencies(self, with_requires: bool) -> Iterable[Optional[Requiremen
543554

544555
valid_extras = self._calculate_valid_requested_extras()
545556
for r in self.base.dist.iter_dependencies(valid_extras):
546-
requirement = factory.make_requirement_from_spec(
547-
str(r), self.base._ireq, valid_extras
557+
yield from factory.make_requirements_from_spec(
558+
str(r),
559+
self._comes_from,
560+
valid_extras,
548561
)
549-
if requirement:
550-
yield requirement
551562

552563
def get_install_requirement(self) -> Optional[InstallRequirement]:
553564
# We don't return anything here, because we always

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

Lines changed: 86 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
ExplicitRequirement,
6363
RequiresPythonRequirement,
6464
SpecifierRequirement,
65+
SpecifierWithoutExtrasRequirement,
6566
UnsatisfiableRequirement,
6667
)
6768

@@ -141,12 +142,14 @@ def _make_extras_candidate(
141142
self,
142143
base: BaseCandidate,
143144
extras: FrozenSet[str],
145+
*,
146+
comes_from: Optional[InstallRequirement] = None,
144147
) -> ExtrasCandidate:
145148
cache_key = (id(base), frozenset(canonicalize_name(e) for e in extras))
146149
try:
147150
candidate = self._extras_candidate_cache[cache_key]
148151
except KeyError:
149-
candidate = ExtrasCandidate(base, extras)
152+
candidate = ExtrasCandidate(base, extras, comes_from=comes_from)
150153
self._extras_candidate_cache[cache_key] = candidate
151154
return candidate
152155

@@ -163,7 +166,7 @@ def _make_candidate_from_dist(
163166
self._installed_candidate_cache[dist.canonical_name] = base
164167
if not extras:
165168
return base
166-
return self._make_extras_candidate(base, extras)
169+
return self._make_extras_candidate(base, extras, comes_from=template)
167170

168171
def _make_candidate_from_link(
169172
self,
@@ -225,7 +228,7 @@ def _make_candidate_from_link(
225228

226229
if not extras:
227230
return base
228-
return self._make_extras_candidate(base, extras)
231+
return self._make_extras_candidate(base, extras, comes_from=template)
229232

230233
def _iter_found_candidates(
231234
self,
@@ -387,16 +390,21 @@ def find_candidates(
387390
if ireq is not None:
388391
ireqs.append(ireq)
389392

390-
# If the current identifier contains extras, add explicit candidates
391-
# from entries from extra-less identifier.
393+
# If the current identifier contains extras, add requires and explicit
394+
# candidates from entries from extra-less identifier.
392395
with contextlib.suppress(InvalidRequirement):
393396
parsed_requirement = get_requirement(identifier)
394-
explicit_candidates.update(
395-
self._iter_explicit_candidates_from_base(
396-
requirements.get(parsed_requirement.name, ()),
397-
frozenset(parsed_requirement.extras),
398-
),
399-
)
397+
if parsed_requirement.name != identifier:
398+
explicit_candidates.update(
399+
self._iter_explicit_candidates_from_base(
400+
requirements.get(parsed_requirement.name, ()),
401+
frozenset(parsed_requirement.extras),
402+
),
403+
)
404+
for req in requirements.get(parsed_requirement.name, []):
405+
_, ireq = req.get_candidate_lookup()
406+
if ireq is not None:
407+
ireqs.append(ireq)
400408

401409
# Add explicit candidates from constraints. We only do this if there are
402410
# known ireqs, which represent requirements not already explicit. If
@@ -439,37 +447,49 @@ def find_candidates(
439447
and all(req.is_satisfied_by(c) for req in requirements[identifier])
440448
)
441449

442-
def _make_requirement_from_install_req(
450+
def _make_requirements_from_install_req(
443451
self, ireq: InstallRequirement, requested_extras: Iterable[str]
444-
) -> Optional[Requirement]:
452+
) -> Iterator[Requirement]:
453+
"""
454+
Returns requirement objects associated with the given InstallRequirement. In
455+
most cases this will be a single object but the following special cases exist:
456+
- the InstallRequirement has markers that do not apply -> result is empty
457+
- the InstallRequirement has both a constraint and extras -> result is split
458+
in two requirement objects: one with the constraint and one with the
459+
extra. This allows centralized constraint handling for the base,
460+
resulting in fewer candidate rejections.
461+
"""
445462
if not ireq.match_markers(requested_extras):
446463
logger.info(
447464
"Ignoring %s: markers '%s' don't match your environment",
448465
ireq.name,
449466
ireq.markers,
450467
)
451-
return None
452-
if not ireq.link:
453-
return SpecifierRequirement(ireq)
454-
self._fail_if_link_is_unsupported_wheel(ireq.link)
455-
cand = self._make_candidate_from_link(
456-
ireq.link,
457-
extras=frozenset(ireq.extras),
458-
template=ireq,
459-
name=canonicalize_name(ireq.name) if ireq.name else None,
460-
version=None,
461-
)
462-
if cand is None:
463-
# There's no way we can satisfy a URL requirement if the underlying
464-
# candidate fails to build. An unnamed URL must be user-supplied, so
465-
# we fail eagerly. If the URL is named, an unsatisfiable requirement
466-
# can make the resolver do the right thing, either backtrack (and
467-
# maybe find some other requirement that's buildable) or raise a
468-
# ResolutionImpossible eventually.
469-
if not ireq.name:
470-
raise self._build_failures[ireq.link]
471-
return UnsatisfiableRequirement(canonicalize_name(ireq.name))
472-
return self.make_requirement_from_candidate(cand)
468+
elif not ireq.link:
469+
if ireq.extras and ireq.req is not None and ireq.req.specifier:
470+
yield SpecifierWithoutExtrasRequirement(ireq)
471+
yield SpecifierRequirement(ireq)
472+
else:
473+
self._fail_if_link_is_unsupported_wheel(ireq.link)
474+
cand = self._make_candidate_from_link(
475+
ireq.link,
476+
extras=frozenset(ireq.extras),
477+
template=ireq,
478+
name=canonicalize_name(ireq.name) if ireq.name else None,
479+
version=None,
480+
)
481+
if cand is None:
482+
# There's no way we can satisfy a URL requirement if the underlying
483+
# candidate fails to build. An unnamed URL must be user-supplied, so
484+
# we fail eagerly. If the URL is named, an unsatisfiable requirement
485+
# can make the resolver do the right thing, either backtrack (and
486+
# maybe find some other requirement that's buildable) or raise a
487+
# ResolutionImpossible eventually.
488+
if not ireq.name:
489+
raise self._build_failures[ireq.link]
490+
yield UnsatisfiableRequirement(canonicalize_name(ireq.name))
491+
else:
492+
yield self.make_requirement_from_candidate(cand)
473493

474494
def collect_root_requirements(
475495
self, root_ireqs: List[InstallRequirement]
@@ -490,30 +510,51 @@ def collect_root_requirements(
490510
else:
491511
collected.constraints[name] = Constraint.from_ireq(ireq)
492512
else:
493-
req = self._make_requirement_from_install_req(
494-
ireq,
495-
requested_extras=(),
513+
reqs = list(
514+
self._make_requirements_from_install_req(
515+
ireq,
516+
requested_extras=(),
517+
)
496518
)
497-
if req is None:
519+
if not reqs:
498520
continue
499-
if ireq.user_supplied and req.name not in collected.user_requested:
500-
collected.user_requested[req.name] = i
501-
collected.requirements.append(req)
521+
template = reqs[0]
522+
if ireq.user_supplied and template.name not in collected.user_requested:
523+
collected.user_requested[template.name] = i
524+
collected.requirements.extend(reqs)
525+
# Put requirements with extras at the end of the root requires. This does not
526+
# affect resolvelib's picking preference but it does affect its initial criteria
527+
# population: by putting extras at the end we enable the candidate finder to
528+
# present resolvelib with a smaller set of candidates to resolvelib, already
529+
# taking into account any non-transient constraints on the associated base. This
530+
# means resolvelib will have fewer candidates to visit and reject.
531+
# Python's list sort is stable, meaning relative order is kept for objects with
532+
# the same key.
533+
collected.requirements.sort(key=lambda r: r.name != r.project_name)
502534
return collected
503535

504536
def make_requirement_from_candidate(
505537
self, candidate: Candidate
506538
) -> ExplicitRequirement:
507539
return ExplicitRequirement(candidate)
508540

509-
def make_requirement_from_spec(
541+
def make_requirements_from_spec(
510542
self,
511543
specifier: str,
512544
comes_from: Optional[InstallRequirement],
513545
requested_extras: Iterable[str] = (),
514-
) -> Optional[Requirement]:
546+
) -> Iterator[Requirement]:
547+
"""
548+
Returns requirement objects associated with the given specifier. In most cases
549+
this will be a single object but the following special cases exist:
550+
- the specifier has markers that do not apply -> result is empty
551+
- the specifier has both a constraint and extras -> result is split
552+
in two requirement objects: one with the constraint and one with the
553+
extra. This allows centralized constraint handling for the base,
554+
resulting in fewer candidate rejections.
555+
"""
515556
ireq = self._make_install_req_from_spec(specifier, comes_from)
516-
return self._make_requirement_from_install_req(ireq, requested_extras)
557+
return self._make_requirements_from_install_req(ireq, requested_extras)
517558

518559
def make_requires_python_requirement(
519560
self,

0 commit comments

Comments
 (0)