From c415ee4cd87915191b1ca8df2d3fbf7e49d4bbbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Barrois?= Date: Sat, 24 Aug 2019 14:04:55 +0200 Subject: Add `Version.precedence_key`. This will be used in `sort(..., key=lambda v: v.precedence_key)`. Remove previous comparison/ordering implementation. The current implementation relies on a 4-tuple: - major, minor, patch (as integers) - natural order matches precedence rules - a tuple of identifiers for the prerelease component. The identifiers for the prerelease components are based on: - A `NumericIdentifier` class, that compares number using their natural order, and always compares lower than non-numeric identifiers - A `AlphaIdentifier` class for non-numeric identifiers; it compares versions using ASCII ordering. - A `MaxIdentifier` class, that compares higher to any other identifier; used to ensure that a non-prerelease version is greater than any of its prereleases. --- ChangeLog | 2 + docs/reference.rst | 7 ++ semantic_version/base.py | 255 +++++++++++++++++++---------------------------- tests/test_base.py | 51 ---------- tests/test_parsing.py | 4 +- 5 files changed, 114 insertions(+), 205 deletions(-) diff --git a/ChangeLog b/ChangeLog index 31ee8aa..9fbcae6 100644 --- a/ChangeLog +++ b/ChangeLog @@ -11,6 +11,8 @@ ChangeLog * Add ``Version.truncate()`` to build a truncated copy of a ``Version`` * Add ``NpmSpec(...)``, following strict NPM matching rules (https://docs.npmjs.com/misc/semver) * Add ``Spec.parse('xxx', syntax='')`` for simpler multi-syntax support + * Add ``Version().precedence_key``, for use in ``sort(versions, key=lambda v: v.precedence_key)`` calls. + The contents of this attribute is an implementation detail. *Bugfix:* diff --git a/docs/reference.rst b/docs/reference.rst index 62a2b3b..3495940 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -128,6 +128,13 @@ Representing a version (the Version class) May be ``None`` for a :attr:`partial` version number in a ````, ``.``, ``..`` or ``..-`` format. + .. attribute:: precedence_key + + Read-only attribute; suited for use in ``sort(versions, key=lambda v: v.precedence_key)``. + 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. .. rubric:: Methods diff --git a/semantic_version/base.py b/semantic_version/base.py index b44227e..0e036ae 100644 --- a/semantic_version/base.py +++ b/semantic_version/base.py @@ -7,13 +7,6 @@ import re import warnings -def _to_int(value): - try: - return int(value), True - except ValueError: - return value, False - - def _has_leading_zero(value): return (value and value[0] == '0' @@ -21,58 +14,66 @@ def _has_leading_zero(value): and value != '0') -def base_cmp(x, y): - if x == y: - return 0 - elif x > y: - return 1 - elif x < y: - return -1 - else: - return NotImplemented +class MaxIdentifier: + __slots__ = [] + + def __repr__(self): + return 'MaxIdentifier()' + + def __eq__(self, other): + return isinstance(other, self.__class__) + +@functools.total_ordering +class NumericIdentifier: + __slots__ = ['value'] -def identifier_cmp(a, b): - """Compare two identifier (for pre-release/build components).""" + def __init__(self, value): + self.value = int(value) - a_cmp, a_is_int = _to_int(a) - b_cmp, b_is_int = _to_int(b) + def __repr__(self): + return 'NumericIdentifier(%r)' % self.value - if a_is_int and b_is_int: - # Numeric identifiers are compared as integers - return base_cmp(a_cmp, b_cmp) - elif a_is_int: - # Numeric identifiers have lower precedence - return -1 - elif b_is_int: - return 1 - else: - # Non-numeric identifiers are compared lexicographically - return base_cmp(a_cmp, b_cmp) + def __eq__(self, other): + if isinstance(other, NumericIdentifier): + return self.value == other.value + return NotImplemented + def __lt__(self, other): + if isinstance(other, MaxIdentifier): + return True + elif isinstance(other, AlphaIdentifier): + return True + elif isinstance(other, NumericIdentifier): + return self.value < other.value + else: + return NotImplemented -def identifier_list_cmp(a, b): - """Compare two identifier list (pre-release/build components). - The rule is: - - Identifiers are paired between lists - - They are compared from left to right - - If all first identifiers match, the longest list is greater. +@functools.total_ordering +class AlphaIdentifier: + __slots__ = ['value'] - >>> identifier_list_cmp(['1', '2'], ['1', '2']) - 0 - >>> identifier_list_cmp(['1', '2a'], ['1', '2b']) - -1 - >>> identifier_list_cmp(['1'], ['1', '2']) - -1 - """ - identifier_pairs = zip(a, b) - for id_a, id_b in identifier_pairs: - cmp_res = identifier_cmp(id_a, id_b) - if cmp_res != 0: - return cmp_res - # alpha1.3 < alpha1.3.1 - return base_cmp(len(a), len(b)) + def __init__(self, value): + self.value = value.encode('ascii') + + def __repr__(self): + return 'AlphaIdentifier(%r)' % self.value + + def __eq__(self, other): + if isinstance(other, AlphaIdentifier): + return self.value == other.value + return NotImplemented + + def __lt__(self, other): + if isinstance(other, MaxIdentifier): + return True + elif isinstance(other, NumericIdentifier): + return False + elif isinstance(other, AlphaIdentifier): + return self.value < other.value + else: + return NotImplemented class Version: @@ -388,127 +389,77 @@ class Version: ', partial=True' if self.partial else '', ) - @classmethod - def _comparison_functions(cls, partial=False): - """Retrieve comparison methods to apply on version components. - - This is a private API. - - Args: - partial (bool): whether to provide 'partial' or 'strict' matching. - - Returns: - 5-tuple of cmp-like functions. - """ - - def prerelease_cmp(a, b): - """Compare prerelease components. - - Special rule: a version without prerelease component has higher - precedence than one with a prerelease component. - """ - if a and b: - return identifier_list_cmp(a, b) - elif a: - # Versions with prerelease field have lower precedence - return -1 - elif b: - return 1 - else: - return 0 - - def build_cmp(a, b): - """Compare build metadata. - - Special rule: there is no ordering on build metadata. - """ - if a == b: - return 0 - else: - return NotImplemented - - def make_optional(orig_cmp_fun): - """Convert a cmp-like function to consider 'None == *'.""" - @functools.wraps(orig_cmp_fun) - def alt_cmp_fun(a, b): - if a is None or b is None: - return 0 - return orig_cmp_fun(a, b) - - return alt_cmp_fun - - if partial: - return [ - base_cmp, # Major is still mandatory - make_optional(base_cmp), - make_optional(base_cmp), - prerelease_cmp, - make_optional(build_cmp), - ] - else: - return [ - base_cmp, - base_cmp, - base_cmp, - prerelease_cmp, - build_cmp, - ] - - def __compare(self, other): - comparison_functions = self._comparison_functions(partial=self.partial or other.partial) - comparisons = zip(comparison_functions, self, other) - - for cmp_fun, self_field, other_field in comparisons: - cmp_res = cmp_fun(self_field, other_field) - if cmp_res != 0: - return cmp_res - - return 0 - def __hash__(self): # We don't include 'partial', since this is strictly equivalent to having # at least a field being `None`. return hash((self.major, self.minor, self.patch, self.prerelease, self.build)) - def __cmp__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - return self.__compare(other) + @property + def precedence_key(self): + if self.prerelease: + prerelease_key = tuple( + NumericIdentifier(part) if part.isdecimal() else AlphaIdentifier(part) + for part in self.prerelease + ) + else: + prerelease_key = ( + MaxIdentifier(), + ) - def __compare_helper(self, other, condition, notimpl_target): - """Helper for comparison. + return ( + self.major, + self.minor, + self.patch, + prerelease_key, + ) - Allows the caller to provide: - - The condition - - The return value if the comparison is meaningless (ie versions with - build metadata). - """ + def __cmp__(self, other): if not isinstance(other, self.__class__): return NotImplemented - - cmp_res = self.__cmp__(other) - if cmp_res is NotImplemented: - return notimpl_target - - return condition(cmp_res) + if self < other: + return -1 + elif self > other: + return 1 + elif self == other: + return 0 + else: + return NotImplemented def __eq__(self, other): - return self.__compare_helper(other, lambda x: x == 0, notimpl_target=False) + if not isinstance(other, self.__class__): + return NotImplemented + return ( + self.major == other.major + and self.minor == other.minor + and self.patch == other.patch + and (self.prerelease or ()) == (other.prerelease or ()) + and (self.build or ()) == (other.build or ()) + ) def __ne__(self, other): - return self.__compare_helper(other, lambda x: x != 0, notimpl_target=True) + if not isinstance(other, self.__class__): + return NotImplemented + return tuple(self) != tuple(other) def __lt__(self, other): - return self.__compare_helper(other, lambda x: x < 0, notimpl_target=False) + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key < other.precedence_key def __le__(self, other): - return self.__compare_helper(other, lambda x: x <= 0, notimpl_target=False) + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key <= other.precedence_key def __gt__(self, other): - return self.__compare_helper(other, lambda x: x > 0, notimpl_target=False) + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key > other.precedence_key def __ge__(self, other): - return self.__compare_helper(other, lambda x: x >= 0, notimpl_target=False) + if not isinstance(other, self.__class__): + return NotImplemented + return self.precedence_key >= other.precedence_key class SpecItem: @@ -600,7 +551,7 @@ class SpecItem: def compare(v1, v2): - return base_cmp(Version(v1), Version(v2)) + return Version(v1).__cmp__(Version(v2)) def match(spec, version): diff --git a/tests/test_base.py b/tests/test_base.py index d5794b3..5a6497b 100755 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -10,57 +10,6 @@ import unittest from semantic_version import base -class ComparisonTestCase(unittest.TestCase): - def test_identifier_cmp(self): - cases = [ - # Integers - ('1', '1', 0), - ('1', '2', -1), - ('11', '2', 1), - ('3333', '40', 1), - - # Text - ('aa', 'ab', -1), - ('aa', 'aa', 0), - ('ab', 'aa', 1), - ('aaa', 'ab', -1), - - # Mixed - ('10', '1a', -1), - ('1a', '10', 1), - ('ab1', '42', 1), - ] - - for a, b, expected in cases: - with self.subTest(a=a, b=b): - result = base.identifier_cmp(a, b) - self.assertEqual( - expected, result, - "identifier_cmp(%r, %r) returned %d instead of %d" % ( - a, b, result, expected)) - - def test_identifier_list_cmp(self): - cases = [ - # Same length - (['1', '2', '3'], ['1', '2', '3'], 0), - (['1', '2', '3'], ['1', '3', '2'], -1), - (['1', '2', '4'], ['1', '2', '3'], 1), - - # Mixed lengths - (['1', 'a'], ['1', 'a', '0'], -1), - (['1', 'a', '0'], ['1', 'a'], 1), - (['1', 'b'], ['1', 'a', '1000'], 1), - ] - - for a, b, expected in cases: - with self.subTest(a=a, b=b): - result = base.identifier_list_cmp(a, b) - self.assertEqual( - expected, result, - "identifier_list_cmp(%r, %r) returned %d instead of %d" % ( - a, b, result, expected)) - - class TopLevelTestCase(unittest.TestCase): """Test module-level functions.""" diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 2d612a1..0da679f 100755 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -120,9 +120,9 @@ class ComparisonTestCase(unittest.TestCase): self.assertFalse(v1 == v2, "%r == %r" % (v1, v2)) self.assertTrue(v1 != v2, "%r !!= %r" % (v1, v2)) self.assertFalse(v1 < v2, "%r !< %r" % (v1, v2)) - self.assertFalse(v1 <= v2, "%r !<= %r" % (v1, v2)) + self.assertTrue(v1 <= v2, "%r !<= %r" % (v1, v2)) self.assertFalse(v2 > v1, "%r !> %r" % (v2, v1)) - self.assertFalse(v2 >= v1, "%r !>= %r" % (v2, v1)) + self.assertTrue(v2 >= v1, "%r !>= %r" % (v2, v1)) if __name__ == '__main__': # pragma: no cover -- cgit v1.2.1