diff options
-rw-r--r-- | ChangeLog | 30 | ||||
-rw-r--r-- | README.rst | 13 | ||||
-rw-r--r-- | docs/reference.rst | 48 | ||||
-rw-r--r-- | semantic_version/base.py | 73 | ||||
-rw-r--r-- | semantic_version/compat.py | 23 | ||||
-rwxr-xr-x | tests/test_base.py | 44 | ||||
-rwxr-xr-x | tests/test_match.py | 32 | ||||
-rwxr-xr-x | tests/test_parsing.py | 35 | ||||
-rw-r--r-- | tests/test_spec.py | 7 |
9 files changed, 192 insertions, 113 deletions
@@ -1,6 +1,36 @@ ChangeLog ========= +2.5.0 (master) +-------------- + +*Bugfix:* + + `#18 <https://github.com/rbarrois/python-semanticversion/issues/18>`_: According to SemVer 2.0.0, build numbers aren't ordered. + + * Remove specs of the ``Spec('<1.1.3+')`` form + * Comparing ``Version('0.1.0')`` to ``Version('0.1.0+bcd')`` has new + rules:: + + >>> Version('0.1.0+1') == Version('0.1.0+bcd') + False + >>> Version('0.1.0+1') != Version('0.1.0+bcd') + True + >>> Version('0.1.0+1') < Version('0.1.0+bcd') + False + >>> Version('0.1.0+1') > Version('0.1.0+bcd') + False + >>> Version('0.1.0+1') <= Version('0.1.0+bcd') + False + >>> Version('0.1.0+1') >= Version('0.1.0+bcd') + False + >>> compare(Version('0.1.0+1'), Version('0.1.0+bcd')) + NotImplemented + + * :func:`semantic_version.compare` returns ``NotImplemented`` when its + parameters differ only by build metadata + * ``Spec('<=1.3.0')`` now matches ``Version('1.3.0+abde24fe883')`` + 2.4.2 (2015-07-02) ------------------ @@ -247,19 +247,18 @@ definition or (for the empty pre-release number) if a single dash is appended False -Including build identifiers in specifications -""""""""""""""""""""""""""""""""""""""""""""" +Including build metadata in specifications +"""""""""""""""""""""""""""""""""""""""""" -The same rule applies for the build identifier: comparisons will include it only -if it was included in the :class:`Spec` definition, or - for the unnumbered build -version - if a single + is appended to the definition(``1.0.0+``, ``1.0.0-alpha+``): +Build metadata has no ordering; thus, the only meaningful comparison including +build metadata is equality. .. code-block:: pycon - >>> Version('1.0.0+build2') in Spec('<=1.0.0') # Build identifier ignored + >>> Version('1.0.0+build2') in Spec('<=1.0.0') # Build metadata ignored True - >>> Version('1.0.0+build2') in Spec('<=1.0.0+') # Include build in checks + >>> Version('1.0.0+build2') in Spec('==1.0.0+build2') # Include build in checks False diff --git a/docs/reference.rst b/docs/reference.rst index 3550c25..261e738 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -22,9 +22,13 @@ Module-level functions :param str v1: The first version to compare :param str v2: The second version to compare :raises: :exc:`ValueError`, if any version string is invalid - :rtype: ``int``, -1 / 0 / 1 as for a :func:`cmp` comparison + :rtype: ``int``, -1 / 0 / 1 as for a :func:`cmp` comparison; + ``NotImplemented`` if versions only differ by build metadata +.. warning:: Since build metadata has no ordering, + ``compare(Version('0.1.1'), Version('0.1.1+3'))`` returns ``NotImplemented`` + .. function:: match(spec, version) @@ -107,9 +111,9 @@ Representing a version (the Version class) .. attribute:: build - ``tuple`` of ``strings``, the build component. + ``tuple`` of ``strings``, the build metadata. - It contains the various dot-separated identifiers in the build component. + It contains the various dot-separated identifiers in the build metadata. May be ``None`` for a :attr:`partial` version number in a ``<major>``, ``<major>.<minor>``, ``<major>.<minor>.<patch>`` or ``<major>.<minor>.<patch>-<prerelease>`` format. @@ -151,7 +155,7 @@ Representing a version (the Version class) For instance, ``Version('1.0', partial=True)`` means "any version beginning in ``1.0``". ``Version('1.0.1-alpha', partial=True)`` means "The ``1.0.1-alpha`` version or any - ulterior build of that same version": ``1.0.1-alpha+build3`` matches, ``1.0.1-alpha.2`` doesn't. + any release differing only in build metadata": ``1.0.1-alpha+build3`` matches, ``1.0.1-alpha.2`` doesn't. Examples:: @@ -246,7 +250,6 @@ The main issue with representing version specifications is that the usual syntax does not map well onto `SemVer`_ precedence rules: * A specification of ``<1.3.4`` is not expected to allow ``1.3.4-rc2``, but strict `SemVer`_ comparisons allow it ; -* Converting the previous specification to ``<=1.3.3`` in order to avoid ``1.3.4`` prereleases has the issue of excluding ``1.3.3+build3`` ; * It may be necessary to exclude either all variations on a patch-level release (``!=1.3.3``) or specifically one build-level release (``1.3.3-build.434``). @@ -256,7 +259,7 @@ In order to have version specification behave naturally, the rules are the follo * If no pre-release number was included in the specification, pre-release numbers are ignored when deciding whether a version satisfies a specification. -* If no build number was included in the specification, build numbers are ignored +* If no build metadata was included in the specification, build metadata is ignored when deciding whether a version satisfies a specification. This means that:: @@ -267,7 +270,7 @@ This means that:: True >>> Version('1.1.1-rc1+build4') in Spec('<=1.1.1-rc1') True - >>> Version('1.1.1-rc1+build4') in Spec('<=1.1.1-rc1+build2') + >>> Version('1.1.1-rc1+build4') in Spec('==1.1.1-rc1+build2') False @@ -285,20 +288,31 @@ rules apply: >>> Version('1.1.1-rc1') in Spec('<1.1.1-') True -* Setting a build separator without a build identifier (``>1.1.1+``) forces - satisfaction tests to include both prerelease and build identifiers:: +* Setting a build metadata separator without build metadata (``<=1.1.1+``) + forces matches "up to the build metadata"; use this to include/exclude a + release lacking build metadata while excluding/including all other builds + of that release - >>> Version('1.1.1+build2') in Spec('>1.1.1') - False - >>> Version('1.1.1+build2') in Spec('>1.1.1+') + >>> Version('1.1.1') in Spec('==1.1.1+') True + >>> Version('1.1.1+2') in Spec('==1.1.1+') + False + + +.. warning:: As stated in the `SemVer`_ specification, the ordering of build metadata is *undefined*. + Thus, a :class:`Spec` string can only mention build metadata to include or exclude a specific version: + + * ``==1.1.1+b1234`` includes this specific build + * ``!=1.1.1+b1234`` excludes it (but would match ``1.1.1+b1235`` + * ``<1.1.1+b1`` is invalid + .. class:: Spec(spec_string[, spec_string[, ...]]) Stores a list of :class:`SpecItem` and matches any :class:`Version` against all contained :class:`specs <SpecItem>`. - It is build from a comma-separated list of version specifications:: + It is built from a comma-separated list of version specifications:: >>> Spec('>=1.0.0,<1.2.0,!=1.1.4') <Spec: ( @@ -427,16 +441,16 @@ rules apply: >>> SpecItem('>=0.1.1').match(Version('0.1.1-rc1')) # pre-release satisfy conditions True - >>> Version('0.1.1+build2') in SpecItem('>=0.1.1') # build version satisfy specifications + >>> Version('0.1.1+build2') in SpecItem('>=0.1.1') # build metadata is ignored when checking for precedence True >>> >>> # Use the '-' marker to include the pre-release component in checks >>> SpecItem('>=0.1.1-').match(Version('0.1.1-rc1') False - >>> - >>> # Use the '+' marker to include the build identifier in checks - >>> SpecItem('<=0.1.1-alpha+').match(Version('0.1.1-alpha+build1')) + >>> # Use the '+' marker to include the build metadata in checks + >>> SpecItem('==0.1.1+').match(Version('0.1.1+b1234') False + >>> .. rubric:: Attributes diff --git a/semantic_version/base.py b/semantic_version/base.py index 841c5f3..982fcc8 100644 --- a/semantic_version/base.py +++ b/semantic_version/base.py @@ -290,20 +290,14 @@ class Version(object): return 0 def build_cmp(a, b): - """Compare build components. + """Compare build metadata. - Special rule: a version without build component has lower - precedence than one with a build component. + Special rule: there is no ordering on build metadata. """ - if a and b: - return identifier_list_cmp(a, b) - elif a: - # Versions with build field have higher precedence - return 1 - elif b: - return -1 - else: + if a == b: return 0 + else: + return NotImplemented def make_optional(orig_cmp_fun): """Convert a cmp-like function to consider 'None == *'.""" @@ -332,10 +326,7 @@ class Version(object): build_cmp, ] - def __cmp__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - + def __compare(self, other): field_pairs = zip(self, other) comparison_functions = self._comparison_functions(partial=self.partial or other.partial) comparisons = zip(comparison_functions, self, other) @@ -347,44 +338,48 @@ class Version(object): return 0 - def __eq__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented - - return self.__cmp__(other) == 0 - def __hash__(self): return hash((self.major, self.minor, self.patch, self.prerelease, self.build)) - def __ne__(self, other): + def __cmp__(self, other): if not isinstance(other, self.__class__): return NotImplemented + return self.__compare(other) - return self.__cmp__(other) != 0 + def __compare_helper(self, other, condition, notimpl_target): + """Helper for comparison. - def __lt__(self, other): + Allows the caller to provide: + - The condition + - The return value if the comparison is meaningless (ie versions with + build metadata). + """ if not isinstance(other, self.__class__): return NotImplemented - return self.__cmp__(other) < 0 + cmp_res = self.__cmp__(other) + if cmp_res is NotImplemented: + return notimpl_target - def __le__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented + return condition(cmp_res) - return self.__cmp__(other) <= 0 + def __eq__(self, other): + return self.__compare_helper(other, lambda x: x == 0, notimpl_target=False) - def __gt__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented + def __ne__(self, other): + return self.__compare_helper(other, lambda x: x != 0, notimpl_target=True) - return self.__cmp__(other) > 0 + def __lt__(self, other): + return self.__compare_helper(other, lambda x: x < 0, notimpl_target=False) - def __ge__(self, other): - if not isinstance(other, self.__class__): - return NotImplemented + def __le__(self, other): + return self.__compare_helper(other, lambda x: x <= 0, notimpl_target=False) + + def __gt__(self, other): + return self.__compare_helper(other, lambda x: x > 0, notimpl_target=False) - return self.__cmp__(other) >= 0 + def __ge__(self, other): + return self.__compare_helper(other, lambda x: x >= 0, notimpl_target=False) class SpecItem(object): @@ -420,6 +415,10 @@ class SpecItem(object): kind, version = match.groups() spec = Version(version, partial=True) + if spec.build is not None and kind not in (cls.KIND_EQUAL, cls.KIND_NEQ): + raise ValueError( + "Invalid requirement specification %r: build numbers have no ordering." + % requirement_string) return (kind, spec) def match(self, version): diff --git a/semantic_version/compat.py b/semantic_version/compat.py index bea6f67..4dd60fe 100644 --- a/semantic_version/compat.py +++ b/semantic_version/compat.py @@ -2,17 +2,14 @@ # Copyright (c) 2012-2014 The python-semanticversion project # This code is distributed under the two-clause BSD License. -import sys -is_python2 = (sys.version_info[0] == 2) - -if is_python2: # pragma: no cover - base_cmp = cmp -else: # pragma: no cover - def base_cmp(x, y): - if x < y: - return -1 - elif x > y: - return 1 - else: - return 0 +def base_cmp(x, y): + if x == y: + return 0 + elif x > y: + return 1 + elif x < y: + return -1 + else: + # Fix Py2's behavior: cmp(x, y) returns -1 for unorderable types + return NotImplemented diff --git a/tests/test_base.py b/tests/test_base.py index df5d4ee..ae23d86 100755 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -64,7 +64,7 @@ class TopLevelTestCase(unittest.TestCase): ('0.1.1', '0.1.1', 0), ('0.1.1', '0.1.0', 1), ('0.1.0-alpha', '0.1.0', -1), - ('0.1.0-alpha+2', '0.1.0-alpha', 1), + ('0.1.0-alpha+2', '0.1.0-alpha', NotImplemented), ) def test_compare(self): @@ -179,7 +179,6 @@ class VersionTestCase(unittest.TestCase): '1.1.2': (1, 1, 2, None, None), '1.1.3-rc4.5': (1, 1, 3, ('rc4', '5'), None), '1.0.0-': (1, 0, 0, (), None), - '1.0.0+': (1, 0, 0, (), ()), '1.0.0-rc.1+build.1': (1, 0, 0, ('rc', '1'), ('build', '1')), '1.0.0+0.3.7': (1, 0, 0, (), ('0', '3', '7')), '1.3.7+build': (1, 3, 7, (), ('build',)), @@ -272,18 +271,29 @@ class VersionTestCase(unittest.TestCase): class SpecItemTestCase(unittest.TestCase): + invalids = [ + '<=0.1.1+build3', + '<=0.1.1+', + '>0.2.3-rc2+', + ] + + def test_invalids(self): + for invalid in self.invalids: + with self.assertRaises(ValueError, msg="SpecItem(%r) should be invalid" % invalid): + _v = base.SpecItem(invalid) + components = { '==0.1.0': (base.SpecItem.KIND_EQUAL, 0, 1, 0, None, None), '==0.1.2-rc3': (base.SpecItem.KIND_EQUAL, 0, 1, 2, ('rc3',), None), '==0.1.2+build3.14': (base.SpecItem.KIND_EQUAL, 0, 1, 2, (), ('build3', '14')), - '<=0.1.1+': (base.SpecItem.KIND_LTE, 0, 1, 1, (), ()), + '<=0.1.1': (base.SpecItem.KIND_LTE, 0, 1, 1, None, None), '<0.1.1': (base.SpecItem.KIND_LT, 0, 1, 1, None, None), '<=0.1.1': (base.SpecItem.KIND_LTE, 0, 1, 1, None, None), + '!=0.1.1+': (base.SpecItem.KIND_NEQ, 0, 1, 1, (), ()), '<=0.1.1-': (base.SpecItem.KIND_LTE, 0, 1, 1, (), None), '>=0.2.3-rc2': (base.SpecItem.KIND_GTE, 0, 2, 3, ('rc2',), None), - '>0.2.3-rc2+': (base.SpecItem.KIND_GT, 0, 2, 3, ('rc2',), ()), '>=2.0.0': (base.SpecItem.KIND_GTE, 2, 0, 0, None, None), - '!=0.1.1+': (base.SpecItem.KIND_NEQ, 0, 1, 1, (), ()), + '!=0.1.1+rc3': (base.SpecItem.KIND_NEQ, 0, 1, 1, (), ('rc3',)), '!=0.3.0': (base.SpecItem.KIND_NEQ, 0, 3, 0, None, None), } @@ -335,13 +345,17 @@ class SpecItemTestCase(unittest.TestCase): ['0.2.3-rc3', '0.2.3', '0.2.3+1', '0.2.3-rc2', '0.2.3-rc2+1'], ['0.2.3-rc1', '0.2.2'], ), - '>0.2.3-rc2+': ( - ['0.2.3-rc3', '0.2.3', '0.2.3-rc2+1'], - ['0.2.3-rc1', '0.2.2', '0.2.3-rc2'], + '==0.2.3+': ( + ['0.2.3'], + ['0.2.3+rc1', '0.2.4', '0.2.3-rc2'], + ), + '!=0.2.3-rc2+12': ( + ['0.2.3-rc3', '0.2.3', '0.2.3-rc2+1', '0.2.4', '0.2.3-rc3+12'], + ['0.2.3-rc2+12'], ), - '>2.0.0+': ( - ['2.1.1', '2.0.0+b1', '3.1.4'], - ['1.9.9', '1.9.9999', '2.0.0', '2.0.0-rc4'], + '==2.0.0+b1': ( + ['2.0.0+b1'], + ['2.1.1', '1.9.9', '1.9.9999', '2.0.0', '2.0.0-rc4'], ), '!=0.1.1': ( ['0.1.2', '0.1.0', '1.4.2'], @@ -440,13 +454,17 @@ class SpecTestCase(unittest.TestCase): self.assertTrue(repr(base.SpecItem(spec_text)) in repr(spec_list)) matches = { + # At least 0.1.1 including pre-releases, less than 0.1.2 excluding pre-releases '>=0.1.1,<0.1.2': ( ['0.1.1', '0.1.1+4', '0.1.1-alpha'], ['0.1.2-alpha', '0.1.2', '1.3.4'], ), - '>=0.1.0+,!=0.1.3-rc1,<0.1.4': ( + # At least 0.1.0 without pre-releases, less than 0.1.4 excluding pre-releases, + # neither 0.1.3-rc1 nor any build of that version, + # not 0.1.0+b3 precisely + '>=0.1.0-,!=0.1.3-rc1,!=0.1.0+b3,<0.1.4': ( ['0.1.1', '0.1.0+b4', '0.1.2', '0.1.3-rc2'], - ['0.0.1', '0.1.4', '0.1.4-alpha', '0.1.3-rc1+4', + ['0.0.1', '0.1.0+b3', '0.1.4', '0.1.4-alpha', '0.1.3-rc1+4', '0.1.0-alpha', '0.2.2', '0.1.4-rc1'], ), } diff --git a/tests/test_match.py b/tests/test_match.py index 155a612..6926e0a 100755 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -15,6 +15,7 @@ class MatchTestCase(unittest.TestCase): '<=0.1.4a', '>0.1.1.1', '~0.1.2-rc23,1', + '<0.1.2-rc1.3-14.15+build.2012-01-01.11h34', ] valid_specs = [ @@ -25,7 +26,7 @@ class MatchTestCase(unittest.TestCase): '>0.1.2-rc1', '>=0.1.2-rc1.3.4', '==0.1.2+build42-12.2012-01-01.12h23', - '<0.1.2-rc1.3-14.15+build.2012-01-01.11h34', + '!=0.1.2-rc1.3-14.15+build.2012-01-01.11h34', ] matches = { @@ -53,11 +54,19 @@ class MatchTestCase(unittest.TestCase): '0.1.2', '0.1.2+build4', ], - '<0.1.2+': [ + '!=0.1.2+': [ + '0.1.2+1', + '0.1.2-rc1', + ], + '!=0.1.2-': [ '0.1.1', '0.1.2-rc1', - '0.1.2-rc1.3.4', - '0.1.2-rc1+build4.5', + ], + '!=0.1.2+345': [ + '0.1.1', + '0.1.2-rc1+345', + '0.1.2+346', + '0.2.3+345', ], '>=0.1.1': [ '0.1.1', @@ -72,12 +81,6 @@ class MatchTestCase(unittest.TestCase): '0.2.0', '1.0.0', ], - '>0.1.1+': [ - '0.1.1+b2', - '0.1.2-rc1', - '1.1.1', - '2.0.4', - ], '<0.1.1-': [ '0.1.1-alpha', '0.1.1-rc4', @@ -87,7 +90,8 @@ class MatchTestCase(unittest.TestCase): def test_invalid(self): for invalid in self.invalid_specs: - self.assertRaises(ValueError, semantic_version.Spec, invalid) + with self.assertRaises(ValueError, msg="Spec(%r) should be invalid" % invalid): + semantic_version.Spec(invalid) def test_simple(self): for valid in self.valid_specs: @@ -122,11 +126,9 @@ class MatchTestCase(unittest.TestCase): self.assertFalse(version in strict_spec, "%r should not be in %r" % (version, strict_spec)) def test_build_check(self): - strict_spec = semantic_version.Spec('<=0.1.1-rc1+') - lax_spec = semantic_version.Spec('<=0.1.1-rc1') + spec = semantic_version.Spec('<=0.1.1-rc1') version = semantic_version.Version('0.1.1-rc1+4.2') - self.assertTrue(version in lax_spec, "%r should be in %r" % (version, lax_spec)) - self.assertFalse(version in strict_spec, "%r should not be in %r" % (version, strict_spec)) + self.assertTrue(version in spec, "%r should be in %r" % (version, spec)) if __name__ == '__main__': # pragma: no cover diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 5112ca5..c7651d2 100755 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -3,6 +3,7 @@ # Copyright (c) 2012-2014 The python-semanticversion project # This code is distributed under the two-clause BSD License. +import itertools import unittest import semantic_version @@ -44,12 +45,8 @@ class ComparisonTestCase(unittest.TestCase): '1.0.0-beta.2', '1.0.0-beta.11', '1.0.0-rc.1', - '1.0.0-rc.1+build.1', '1.0.0', - '1.0.0+0.3.7', '1.3.7+build', - '1.3.7+build.2.b8f12d7', - '1.3.7+build.11.e0f985a', ] def test_comparisons(self): @@ -67,6 +64,36 @@ class ComparisonTestCase(unittest.TestCase): cmp_res = -1 if i < j else (1 if i > j else 0) self.assertEqual(cmp_res, semantic_version.compare(first, second)) + unordered = [ + [ + '1.0.0-rc.1', + '1.0.0-rc.1+build.1', + ], + [ + '1.0.0', + '1.0.0+0.3.7', + ], + [ + '1.3.7', + '1.3.7+build', + '1.3.7+build.2.b8f12d7', + '1.3.7+build.11.e0f985a', + ], + ] + + def test_unordered(self): + for group in self.unordered: + for a, b in itertools.combinations(group, 2): + v1 = semantic_version.Version(a) + v2 = semantic_version.Version(b) + self.assertTrue(v1 == v1, "%r != %r" % (v1, v1)) + self.assertFalse(v1 != v1, "%r != %r" % (v1, v1)) + 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.assertFalse(v2 > v1, "%r !> %r" % (v2, v1)) + self.assertFalse(v2 >= v1, "%r !>= %r" % (v2, v1)) if __name__ == '__main__': # pragma: no cover unittest.main() diff --git a/tests/test_spec.py b/tests/test_spec.py index 7a645f9..a13cb0b 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -154,10 +154,3 @@ class FormatTests(unittest.TestCase): self.assertLess(Version('1.0.0-beta.2'), Version('1.0.0-beta.11')) self.assertLess(Version('1.0.0-beta.11'), Version('1.0.0-rc.1')) self.assertLess(Version('1.0.0-rc.1'), Version('1.0.0')) - - - -class PrecedenceTestCase(unittest.TestCase): - pass - - |