summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ChangeLog2
-rw-r--r--docs/reference.rst7
-rw-r--r--semantic_version/base.py255
-rwxr-xr-xtests/test_base.py51
-rwxr-xr-xtests/test_parsing.py4
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='<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 ``<major>``, ``<major>.<minor>``,
``<major>.<minor>.<patch>`` or ``<major>.<minor>.<patch>-<prerelease>`` 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