Skip to content

Commit

Permalink
Fix resolve selection to work for all resolves. (pex-tool#1647)
Browse files Browse the repository at this point in the history
Previously, a method not compatible with universal locks was used. Unify
the implementation to use the lock style agnostic
`LockedResolve.resolve` method.

Fixes pex-tool#1582
  • Loading branch information
jsirois authored Mar 4, 2022
1 parent 730f85f commit 9890598
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 81 deletions.
20 changes: 19 additions & 1 deletion pex/resolve/locked_resolve.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import absolute_import
from __future__ import absolute_import, division

import itertools
import os
Expand Down Expand Up @@ -294,6 +294,7 @@ class Resolved(object):
@classmethod
def create(
cls,
target, # type: Target
direct_requirements, # type: Iterable[Requirement]
downloadable_requirements, # type: Iterable[_ResolvedArtifact]
):
Expand All @@ -307,7 +308,14 @@ def create(
requirement
)

# N.B.: Lowest rank means highest rank value. I.E.: The 1st tag is the most specific and
# the 765th tag is the least specific.
largest_rank_value = target.supported_tags.lowest_rank.value
smallest_rank_value = TagRank.highest_natural().value
rank_span = largest_rank_value - smallest_rank_value

downloadable_artifacts = []
target_specificities = []
for downloadable_requirement in downloadable_requirements:
pin = downloadable_requirement.locked_requirement.pin
downloadable_artifacts.append(
Expand All @@ -319,11 +327,20 @@ def create(
],
)
)
target_specificities.append(
(
rank_span
- (downloadable_requirement.ranked_artifact.rank.value - smallest_rank_value)
)
/ rank_span
)

return cls(
target_specificity=sum(target_specificities) / len(target_specificities),
downloadable_artifacts=tuple(downloadable_artifacts),
)

target_specificity = attr.ib() # type: float
downloadable_artifacts = attr.ib() # type: Tuple[DownloadableArtifact, ...]


Expand Down Expand Up @@ -630,6 +647,7 @@ def attributed_reason(reason):
)

return Resolved.create(
target=target,
direct_requirements=requirements,
downloadable_requirements=resolved_artifacts,
)
90 changes: 12 additions & 78 deletions pex/resolve/lockfile/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from __future__ import absolute_import, print_function

from pex.resolve.locked_resolve import LockedResolve, LockStyle
from pex.resolve.locked_resolve import LockedResolve, LockStyle, Resolved
from pex.resolve.resolver_configuration import ResolverVersion
from pex.sorted_tuple import SortedTuple
from pex.targets import Target
Expand All @@ -19,67 +19,6 @@
from pex.third_party import attr


@attr.s(frozen=True)
class _RankedLock(object):
@classmethod
def rank(
cls,
locked_resolve, # type: LockedResolve
target, # type: Target
):
# type: (...) -> Optional[_RankedLock]
"""Rank the given resolve for the supported tags of a distribution target.
Pex allows choosing an array of distribution targets as part of building multiplatform PEX
files. Whether via interpreter constraint ranges, multiple `--python` or `--platform`
specifications or some combination of these, parallel resolves will be executed for each
distinct distribution target selected. When generating a lock, Pex similarly will create a
locked resolve per selected distribution target in parallel. Later, at lock consumption
time, there will again be one or more distribution targets that may need to resolve from the
lock. For each of these distribution targets, either one or more of the generated locks will
be applicable or none will. If the distribution target matches one of those used to generate
the lock file, the corresponding locked resolve will clearly work. The distribution target
need not match though for the locked resolve to be usable. All that's needed is for at least
one artifact for each locked requirement in the resolve to be usable by the distribution
target. The classic example is a locked resolve that is populated with only universal
wheels. Even if such a locked resolve was generated by a PyPy 2 interpreter, it should be
usable by a CPython 3.10 interpreter or any other Python 2 or Python 3 interpreter.
To help select which locked resolve to use, ranking gives a score to each locked resolve
that is the average of the score of each locked requirement in the resolve. Each locked
requirement is, in turn, scored by its best matching artifact score. Artifacts are scored as
follows:
+ If the artifact is a wheel, score it based on its best matching tag.
+ If the artifact is an sdist, score it as usable, but a worse match than any wheel.
+ Otherwise treat the artifact as unusable.
If a locked requirement has no matching artifact, the scoring is aborted since the locked
resolve has an unsatisfied requirement and `None` is returned.
:param locked_resolve: The resolve to rank.
:param target: The target looking to pick a resolve to use.
:return: A ranked lock if the resolve is applicable to the target else `None`.
"""
requirement_ranks = []
for req in locked_resolve.locked_requirements:
ranked_artifact = req.select_artifact(target)
if not ranked_artifact:
return None
requirement_ranks.append(ranked_artifact.rank)

if not requirement_ranks:
return None

average_requirement_rank = float(sum(rank.value for rank in requirement_ranks)) / len(
locked_resolve.locked_requirements
)
return cls(average_requirement_rank=average_requirement_rank, locked_resolve=locked_resolve)

average_requirement_rank = attr.ib() # type: float
locked_resolve = attr.ib() # type: LockedResolve


@attr.s(frozen=True)
class Lockfile(object):
@classmethod
Expand Down Expand Up @@ -150,29 +89,24 @@ def select(self, targets):

def _select(self, target):
# type: (Target) -> Optional[LockedResolve]
ranked_locks = [] # type: List[_RankedLock]

resolves = [] # type: List[Tuple[float, LockedResolve]]
for locked_resolve in self.locked_resolves:
ranked_lock = _RankedLock.rank(locked_resolve, target)
if ranked_lock is not None:
ranked_locks.append(ranked_lock)
result = locked_resolve.resolve(target, self.requirements)
if isinstance(result, Resolved):
resolves.append((result.target_specificity, locked_resolve))

if not ranked_locks:
if not resolves:
return None

ranked_lock = sorted(ranked_locks)[0]
count = len(target.supported_tags)
target_specificity, locked_resolve = sorted(resolves)[-1]
TRACER.log(
"Selected lock generated by {platform} with an average requirement rank of "
"{average_requirement_rank:.2f} (out of {count}, so ~{percent:.1%} platform specific) "
"from locks generated by {platforms}".format(
platform=ranked_lock.locked_resolve.platform_tag,
average_requirement_rank=ranked_lock.average_requirement_rank,
count=count,
percent=(count - ranked_lock.average_requirement_rank) / count,
"Selected lock generated by {platform} with an average artifact platform specificity "
"of ~{percent:.1%} from locks generated by {platforms}".format(
platform=locked_resolve.platform_tag,
percent=target_specificity,
platforms=", ".join(
sorted(str(lock.platform_tag) for lock in self.locked_resolves)
),
)
)
return ranked_lock.locked_resolve
return locked_resolve
61 changes: 59 additions & 2 deletions tests/resolve/test_locked_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

from pex import targets
from pex.interpreter import PythonInterpreter
from pex.pep_425 import TagRank
from pex.pep_425 import CompatibilityTags, TagRank
from pex.pep_440 import Version
from pex.pep_503 import ProjectName
from pex.pep_508 import MarkerEnvironment
from pex.platforms import Platform
from pex.resolve.locked_resolve import (
Artifact,
Expand All @@ -22,7 +23,7 @@
from pex.resolve.resolved_requirement import Fingerprint, Pin
from pex.result import Error, try_
from pex.sorted_tuple import SortedTuple
from pex.targets import AbbreviatedPlatform, LocalInterpreter, Target
from pex.targets import AbbreviatedPlatform, CompletePlatform, LocalInterpreter, Target
from pex.third_party.packaging.specifiers import SpecifierSet
from pex.third_party.packaging.tags import Tag
from pex.third_party.pkg_resources import Requirement
Expand Down Expand Up @@ -692,3 +693,59 @@ def test_multiple_errors(
"""
).format(target_description=current_target.render_description()),
)


def test_resolved():
# type: () -> None

def assert_resolved(
expected_target_specificity, # type: float
supported_tag_count, # type: int
artifact_ranks, # type: Iterable[int]
):
# type: (...) -> None
direct_requirements = requirements("foo")

resolved_artifacts = tuple(
resolved_artifact(
project_name="foo",
version=str(rank),
artifact_basename="foo-{}.tar.gz".format(rank),
rank_value=rank,
)
for rank in artifact_ranks
)

downloadable_artifacts = tuple(
DownloadableArtifact.create(
pin=resolved_art.locked_requirement.pin,
artifact=resolved_art.artifact,
satisfied_direct_requirements=direct_requirements,
)
for resolved_art in resolved_artifacts
)

target = CompletePlatform.create(
MarkerEnvironment(),
CompatibilityTags.from_strings(
"py3-none-manylinux_2_{glibc_minor}_x86_64".format(glibc_minor=glibc_minor)
for glibc_minor in range(supported_tag_count)
),
)

assert Resolved(
target_specificity=expected_target_specificity,
downloadable_artifacts=downloadable_artifacts,
) == Resolved.create(
target=target,
direct_requirements=direct_requirements,
downloadable_requirements=resolved_artifacts,
)

# For tag ranks of 1, 2, 1 should rank 100% target specific (best match) and 2 should rank 0%
# (worst match / universal)
assert_resolved(expected_target_specificity=1.0, supported_tag_count=2, artifact_ranks=[1])
assert_resolved(expected_target_specificity=0.0, supported_tag_count=2, artifact_ranks=[2])

# For tag ranks of 1, 2, 3, 2 lands in the middle and should be 50% target specific.
assert_resolved(expected_target_specificity=0.5, supported_tag_count=3, artifact_ranks=[2])

0 comments on commit 9890598

Please sign in to comment.