Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a package orderer that matches Python's version specifier spec (PEP440) #1706

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
36 changes: 36 additions & 0 deletions src/rez/data/tests/packages/py_packages/pep440.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name = 'pep440'

versions = [
# 1.0.0 has a full release, as well as post and local releases
"1.0.0",
"1.0.0.a1",
"1.0.0.a2",
"1.0.0.b1",
"1.0.0.rc1",
"1.0.0.rc2",
"1.0.0.post1",
"1.0.0+local",

# 1.0.1 has a full release
"1.0.1",
"1.0.1.a1",
"1.0.1.b1",
"1.0.1.b2",
"1.0.1.rc1",
"1.0.1.rc2",

# 1.0.2 is in a RC/preview stage
"1.0.2.a1",
"1.0.2.b1",
"1.0.2.rc1",
"1.0.2.rc2",

# 1.1.0 is in beta
"1.1.0.a1",
"1.1.0.b1",
"1.1.0.b2",

# 2.0.0 is in alpha
"2.0.0.a1",
"2.0.0.a2",
]
134 changes: 133 additions & 1 deletion src/rez/package_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from inspect import isclass
from hashlib import sha1
from typing import Dict, Iterable, List, Optional, Union
from typing import Dict, Iterable, List, Optional, Tuple, Union

from rez.config import config
from rez.utils.data_utils import cached_class_property
Expand Down Expand Up @@ -608,6 +608,138 @@ def from_pod(cls, data):
)


class PEP440PackageOrder(PackageOrder):
"""
Here's how this package orderer behaves:
1. First separate versions into two groups based on the value of
``prerelease``: a disallowed/non-preferential group and those which are allowed/preferred
2. Sort each group from step 1 in the order defined by the pep440 spec.
e.g. 1.1 > 1.1rc1 > 1.1b1 > 1.1a1
3. Concatenate the two groups: placing the non-preferred group -- in sorted order --
after the preferred group.
Comment on lines +614 to +619

Choose a reason for hiding this comment

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

I'm quite lost as to why you have to do all this. What's a disallowed or non-preferential group? And what's an allowed and preferred group? Why is there a concept of what's allowed or not or even preferences?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hopefully the above example with pip install helps clarify it.
Essentially, most of the time you want users to get the latest release version, not the absolute latest version. But you also don't want to specifically disallow them from using it. The prerelease argument allows the package sorter to set a risk tolerance for the consumer, so that if I am comfortable with rc, but not beta then I am happy with the highest version number of either rc or release, but not beta or alpha.


Note that since the package orderer does not have the ability to filter the versions
in the non-preferred group it could be possible for a request to choose a non-preferred
version if all preferred versions are eliminated during resolution.

With prerelease set to None, non-prerelease versions will be sorted before all prerelease
versions. With prerelease set to a prerelease string, order will prefer prerelease up
to and including that level of risk tolerance (i.e. "beta", allows for "b" and "rc" releases)
as well as non-prerelease versions.

For example, given the versions [1.0b2, 1.0a1, 1.0, 1.1, 1.0rc1, 1.1b2, 1.2b1],
an orderer initialized with ``prerelease=None`` would give the order:
[1.1, 1.0, 1.2b1, 1.1b2, 1.0rc1, 1.0b2, 1.0a1].
an orderer initialized with ``prerelease="b"`` would give the order:
[1.2b1, 1.1, 1.1b2, 1.0, 1.0rc1, 1.0b2, 1.0a1].
"""

name = "pep440"

# We can normalize all the possible prerelease values to a, b, or rc. For more info, see:
# https://packaging.python.org/en/latest/specifications/version-specifiers/#pre-release-spelling
pep440_prerelease_map = {
None: None,
"release": None,
"a": "a",
"alpha": "a",
"b": "b",
"beta": "b",
"rc": "rc",
"c": "rc",
"pre": "rc",
"preview": "rc",
}

def __init__(self,
prerelease: Optional[str] = None,
packages: Optional[Iterable[str]] = None,
):
super().__init__(packages)
# Raises a KeyError if the value is unknown
self.prerelease = self.pep440_prerelease_map[prerelease]

def sort_key_implementation(self, package_name, version):
"""
Get a sort key for sorting Versions by PEP440 rules.

The prerelease argument allows for risk tolerance to be set, such that we can opt into
allowing preview/rc versions ahead of release versions.
"""
import rez.vendor.packaging.version as pep440
pep440_version = pep440.parse(str(version))

key = pep440_version._key
if not isinstance(pep440_version, pep440.Version):
# Fallback to pep440 legacy sorting if this isn't a compatible version format.
return key

# We want to allow prerelease versions up to a specific level above release versions.
# If the prerelease token is not set, then we will sort release versions to the front.
# To do this, we are going to replace the "pre" component of the sort key tuple
# provided by packaging.
key_list = list(key)
# The packaging key comprises (epoch, release, pre, post, dev, local)
# And since tuples sort lexicographically, we can insert a new tuple item at the
# front of the sort key to impact whether we prefer it or not.
# The "pre" key is a tuple that may contain Infinity (if there is no pre)
# Or a version number along with the prerelease (a, b, rc) token.
# Note that the pre key takes advantage of the alphabetcal sorting of
# a, b, and rc to sort properly.
pre: Tuple[Union[pep440.Infinity, int, str], ...] = key_list[2]

if pre in (pep440.Infinity, -pep440.Infinity):
# Keep the relative order the same for packages that have no prerelease, but
# Make sure the first key forces them to be after our prerelease preference.
risk_allowance_token = pep440.Infinity
elif not self.prerelease:
# We have no risk tolerance, so sort prereleases to the bottom.
risk_allowance_token = -pep440.Infinity
elif pre >= (self.prerelease, ):
# This version is a preprelase, but is within our risk tolerance, so allow
# it to sort ahead with release versions.
risk_allowance_token = pep440.Infinity
else:
# This version should remain below releases, but otherwise sort the same way.
risk_allowance_token = -pep440.Infinity

# Insert the risk allowance sorting key at the front to force higher version
# prereleases below *any* release versions.
key_list.insert(0, risk_allowance_token)
return tuple(key_list)

def __str__(self):
return str(self.prerelease)

def __eq__(self, other):
return (
type(self) == type(other)
and self.prerelease == other.prerelease
)

def to_pod(self):
"""
Example (in yaml):

.. code-block:: yaml

type: pep440
prerelease: "a"
packages: ["foo"]
"""
return {
"prerelease": self.prerelease,
"packages": self.packages,
}

@classmethod
def from_pod(cls, data):
return cls(
prerelease=data.get("prerelease"),
packages=data.get("packages"),
)


class PackageOrderList(list):
"""A list of package orderer.
"""
Expand Down
9 changes: 8 additions & 1 deletion src/rez/tests/test_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@
'timestamped-1.0.5', 'timestamped-1.0.6', 'timestamped-1.1.0', 'timestamped-1.1.1',
'timestamped-1.2.0', 'timestamped-2.0.0', 'timestamped-2.1.0', 'timestamped-2.1.5',
'multi-1.0', 'multi-1.1', 'multi-1.2', 'multi-2.0',
'missing_variant_requires-1'
'missing_variant_requires-1',
'pep440-1.0.0.a1', 'pep440-1.0.0.a2', 'pep440-1.0.0.b1', 'pep440-1.0.0.rc1', 'pep440-1.0.0.rc2',
'pep440-1.0.0', 'pep440-1.0.0.post1', 'pep440-1.0.0+local',
'pep440-1.0.1.a1', 'pep440-1.0.1.b2', 'pep440-1.0.1.b1', 'pep440-1.0.1.rc1', 'pep440-1.0.1.rc2',
'pep440-1.0.1',
'pep440-1.0.2.a1', 'pep440-1.0.2.b1', 'pep440-1.0.2.rc1', 'pep440-1.0.2.rc2',
'pep440-1.1.0.b1', 'pep440-1.1.0.b2', 'pep440-1.1.0.a1',
'pep440-2.0.0.a1', 'pep440-2.0.0.a2',
])


Expand Down
147 changes: 145 additions & 2 deletions src/rez/tests/test_packages_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
import json

from rez.config import config
from rez.package_order import NullPackageOrder, PackageOrder, PerFamilyOrder, VersionSplitPackageOrder, \
TimestampPackageOrder, SortedOrder, PackageOrderList, from_pod
from rez.package_order import (
NullPackageOrder, PackageOrder, PerFamilyOrder, VersionSplitPackageOrder,
TimestampPackageOrder, SortedOrder, PEP440PackageOrder, PackageOrderList, from_pod)
from rez.packages import iter_packages
from rez.tests.util import TestBase, TempdirMixin
from rez.version import Version
Expand Down Expand Up @@ -289,6 +290,148 @@ def test_pod(self):
self._test_pod(TimestampPackageOrder(timestamp=3001, rank=3))


class TestPEP440PackageOrder(_BaseTestPackagesOrder):
"""Test case for the PEP440PackageOrder class"""

def test_reorder(self):
"""Validate package ordering with a PEP440PackageOrder"""
# First test that we can sort packages prerelease=None,
# so we expect non-prerelease versions on top.
orderer = PEP440PackageOrder()
expected = [
# Release and post releases first.
'1.0.1',
'1.0.0.post1',
'1.0.0+local',
'1.0.0',

# Followed by higher version prerelease versions, sorted according to pep440
'2.0.0.a2',
'2.0.0.a1',
'1.1.0.b2',
'1.1.0.b1',
'1.1.0.a1',
'1.0.2.rc2',
'1.0.2.rc1',
'1.0.2.b1',
'1.0.2.a1',
'1.0.1.rc2',
'1.0.1.rc1',
'1.0.1.b2',
'1.0.1.b1',
'1.0.1.a1',
'1.0.0.rc2',
'1.0.0.rc1',
'1.0.0.b1',
'1.0.0.a2',
'1.0.0.a1',
]
self._test_reorder(orderer, "pep440", expected)

# Test that we can allow RC versions ahead of release
orderer = PEP440PackageOrder(prerelease="rc")
expected = [
# We allow beta and RC versions to sort ahead of release.
'1.0.2.rc2',
'1.0.2.rc1',
'1.0.1',
'1.0.1.rc2',
'1.0.1.rc1',
'1.0.0.post1',
'1.0.0+local',
'1.0.0',
'1.0.0.rc2',
'1.0.0.rc1',

# Followed by alpha and beta versions, which are not preferred.
'2.0.0.a2',
'2.0.0.a1',
'1.1.0.b2',
'1.1.0.b1',
'1.1.0.a1',
'1.0.2.b1',
'1.0.2.a1',
'1.0.1.b2',
'1.0.1.b1',
'1.0.1.a1',
'1.0.0.b1',
'1.0.0.a2',
'1.0.0.a1',
]
self._test_reorder(orderer, "pep440", expected)

# Test allowing beta releases to be preferred
orderer = PEP440PackageOrder(prerelease="b")
expected = [
# We allow beta and RC versions to sort ahead of release.
'1.1.0.b2',
'1.1.0.b1',
'1.0.2.rc2',
'1.0.2.rc1',
'1.0.2.b1',
'1.0.1',
'1.0.1.rc2',
'1.0.1.rc1',
'1.0.1.b2',
'1.0.1.b1',
'1.0.0.post1',
'1.0.0+local',
'1.0.0',
'1.0.0.rc2',
'1.0.0.rc1',
'1.0.0.b1',

# Followed by alpha versions, which are not preferred.
'2.0.0.a2',
'2.0.0.a1',
'1.1.0.a1',
'1.0.2.a1',
'1.0.1.a1',
'1.0.0.a2',
'1.0.0.a1',
]
self._test_reorder(orderer, "pep440", expected)

# Test that we can get access to alpha releases if requested
orderer = PEP440PackageOrder(prerelease="a")
expected = [
# We allow all prereleases to sort ahead of release.
'2.0.0.a2',
'2.0.0.a1',
'1.1.0.b2',
'1.1.0.b1',
'1.1.0.a1',
'1.0.2.rc2',
'1.0.2.rc1',
'1.0.2.b1',
'1.0.2.a1',
'1.0.1',
'1.0.1.rc2',
'1.0.1.rc1',
'1.0.1.b2',
'1.0.1.b1',
'1.0.1.a1',
'1.0.0.post1',
'1.0.0+local',
'1.0.0',
'1.0.0.rc2',
'1.0.0.rc1',
'1.0.0.b1',
'1.0.0.a2',
'1.0.0.a1',
]
self._test_reorder(orderer, "pep440", expected)

def test_repr(self):
"""Validate we can represent a PEP440PackageOrder as a string."""
inst = PEP440PackageOrder(prerelease="a")
self.assertEqual("PEP440PackageOrder(a)", repr(inst))

def test_pod(self):
"""Validate we can save and load a PEP440PackageOrder to its pod representation."""
self._test_pod(PEP440PackageOrder(prerelease="a"))


class TestPackageOrdererList(_BaseTestPackagesOrder):
"""Test cases for the PackageOrderList class."""

Expand Down