Skip to content

Commit

Permalink
Guarantee a stable ordering with build metadata
Browse files Browse the repository at this point in the history
Sorting any permutation of Version objects should always yield the same
result, even if those hold some build metadata.

To that end, the "precedence_key" is now used exclusively for sorting;
direct comparisons between Version objects still ignores the "build"
metadata, using a different precedence key.

For performance improvements, both precedence keys are cached.

Closes: #132
  • Loading branch information
rbarrois committed May 26, 2022
1 parent 7dcc42d commit 57c78e7
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 11 deletions.
10 changes: 7 additions & 3 deletions ChangeLog
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
ChangeLog
=========

2.9.1 (unreleased)
------------------
2.10.0 (unreleased)
-------------------

*New:*

- Nothing changed yet.
* `132 <https://github.com/rbarrois/python-semanticversion/issues/132>`_:
Ensure sorting a collection of versions is always stable, even with
build metadata.


2.9.0 (2022-02-06)
Expand Down
11 changes: 10 additions & 1 deletion docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,16 @@ Representing a version (the Version class)
The actual value of the attribute is considered an implementation detail; the only
guarantee is that ordering versions by their precedence_key will comply with semver precedence rules.

Note that the :attr:`~Version.build` isn't included in the precedence_key computatin.

.. warning::

.. versionchanged:: 2.10.0

The :attr:`~Version.build` is included in the precedence_key computation, but
only for ordering stability.
The only guarantee is that, for a given release of python-semanticversion, two versions'
:attr:`~Version.precedence_key` will always compare in the same direction if they include
build metadata; that ordering is an implementation detail and shouldn't be relied upon.

.. attribute:: partial

Expand Down
42 changes: 35 additions & 7 deletions semantic_version/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,12 @@ def __init__(

self.partial = partial

# Cached precedence keys
# _cmp_precedence_key is used for semver-precedence comparison
self._cmp_precedence_key = self._build_precedence_key(with_build=False)
# _sort_precedence_key is used for self.precedence_key, esp. for sorted(...)
self._sort_precedence_key = self._build_precedence_key(with_build=True)

@classmethod
def _coerce(cls, value, allow_none=False):
if value is None and allow_none:
Expand Down Expand Up @@ -408,25 +414,47 @@ def __hash__(self):
# at least a field being `None`.
return hash((self.major, self.minor, self.patch, self.prerelease, self.build))

@property
def precedence_key(self):
def _build_precedence_key(self, with_build=False):
"""Build a precedence key.
The "build" component should only be used when sorting an iterable
of versions.
"""
if self.prerelease:
prerelease_key = tuple(
NumericIdentifier(part) if re.match(r'^[0-9]+$', part) else AlphaIdentifier(part)
NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part)
for part in self.prerelease
)
else:
prerelease_key = (
MaxIdentifier(),
)

if not with_build:
return (
self.major,
self.minor,
self.patch,
prerelease_key,
)

build_key = tuple(
NumericIdentifier(part) if part.isdigit() else AlphaIdentifier(part)
for part in self.build or ()
)

return (
self.major,
self.minor,
self.patch,
prerelease_key,
build_key,
)

@property
def precedence_key(self):
return self._sort_precedence_key

def __cmp__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
Expand Down Expand Up @@ -458,22 +486,22 @@ def __ne__(self, other):
def __lt__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
return self.precedence_key < other.precedence_key
return self._cmp_precedence_key < other._cmp_precedence_key

def __le__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
return self.precedence_key <= other.precedence_key
return self._cmp_precedence_key <= other._cmp_precedence_key

def __gt__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
return self.precedence_key > other.precedence_key
return self._cmp_precedence_key > other._cmp_precedence_key

def __ge__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
return self.precedence_key >= other.precedence_key
return self._cmp_precedence_key >= other._cmp_precedence_key


class SpecItem(object):
Expand Down
14 changes: 14 additions & 0 deletions tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,20 @@ def test_invalid_comparisons(self):
self.assertTrue(v != '0.1.0')
self.assertFalse(v == '0.1.0')

def test_stable_ordering(self):
a = [
base.Version('0.1.0'),
base.Version('0.1.0+a'),
base.Version('0.1.0+a.1'),
base.Version('0.1.1-a1'),
]
b = [a[1], a[3], a[0], a[2]]

self.assertEqual(
sorted(a, key=lambda v: v.precedence_key),
sorted(b, key=lambda v: v.precedence_key),
)

def test_bump_clean_versions(self):
# We Test each property explicitly as the == comparator for versions
# does not distinguish between prerelease or builds for equality.
Expand Down

0 comments on commit 57c78e7

Please sign in to comment.