summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRaphaël Barrois <raphael.barrois@polytechnique.org>2019-08-23 23:24:50 +0200
committerRaphaël Barrois <raphael.barrois@polytechnique.org>2019-08-26 21:34:10 +0200
commitc4c6ab0e925d8cfabb68d34a10a783cb854b63a0 (patch)
tree84895b9dd586594c380668875ede00ba4b8a928a
parent5b9174aedaf9843ee5b3b6358461910e328e74d1 (diff)
downloadsemantic-version-c4c6ab0e925d8cfabb68d34a10a783cb854b63a0.tar.gz
Add support for NPM-style version ranges.
The code follows closely the specification available at https://docs.npmjs.com/misc/semver.html. Despite similarities, the matching logic is fully separate from the `native` code, since both might evolve at their own scales.
-rw-r--r--ChangeLog1
-rw-r--r--README.rst16
-rw-r--r--docs/reference.rst16
-rw-r--r--semantic_version/__init__.py2
-rw-r--r--semantic_version/base.py194
-rw-r--r--tests/django_test_app/models.py1
-rw-r--r--tests/test_django.py19
-rw-r--r--tests/test_npm.py88
8 files changed, 333 insertions, 4 deletions
diff --git a/ChangeLog b/ChangeLog
index 169bd2a..8ef6048 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -9,6 +9,7 @@ ChangeLog
* Allow creation of a ``Version`` directly from parsed components, as keyword arguments
(``Version(major=1, minor=2, patch=3)``)
* Add ``Version.truncate()`` to build a truncated copy of a ``Version``
+ * Add ``NpmSpec(...)``, following strict NPM matching rules (https://docs.npmjs.com/misc/semver)
*Bugfix:*
diff --git a/README.rst b/README.rst
index 0d0d421..257b2a4 100644
--- a/README.rst
+++ b/README.rst
@@ -280,6 +280,22 @@ build metadata is equality.
False
+NPM-based ranges
+----------------
+
+The :class:`NpmSpec` class handles NPM-style ranges:
+
+.. code-block:: pycon
+
+ >>> Version('1.2.3') in NpmSpec('1.2.2 - 1.4')
+ True
+ >>> Version('1.2.3') in NpmSpec('<1.x || >=1.2.3')
+ True
+
+Refer to https://docs.npmjs.com/misc/semver.html for a detailed description of NPM
+range syntax.
+
+
Using with Django
=================
diff --git a/docs/reference.rst b/docs/reference.rst
index 8caa30b..feaffcf 100644
--- a/docs/reference.rst
+++ b/docs/reference.rst
@@ -512,6 +512,22 @@ rules apply:
:rtype: ``(*spec)`` tuple
+.. class:: NpmSpec(spec_string)
+
+ .. versionadded:: 2.8
+
+ A NPM-compliant version matching engine, based on the https://docs.npmjs.com/misc/semver.html specification.
+
+ .. code-block:: pycon
+
+ >>> Version('0.1.2') in NpmSpec('0.1.0-alpha.2 .. 0.2.4')
+ True
+ >>> Version('0.1.2') in NpmSpec('>=0.1.1 <0.1.3 || 2.x')
+ True
+ >>> Version('2.3.4') in NpmSpec('>=0.1.1 <0.1.3 || 2.x')
+ True
+
+
.. class:: SpecItem(spec_string)
.. note:: This class belong to the private python-semanticversion API.
diff --git a/semantic_version/__init__.py b/semantic_version/__init__.py
index ca30919..e04e0ba 100644
--- a/semantic_version/__init__.py
+++ b/semantic_version/__init__.py
@@ -3,7 +3,7 @@
# This code is distributed under the two-clause BSD License.
-from .base import compare, match, validate, NativeSpec, Spec, SpecItem, Version
+from .base import compare, match, validate, NativeSpec, NpmSpec, Spec, SpecItem, Version
__author__ = "Raphaël Barrois <raphael.barrois+semver@polytechnique.org>"
diff --git a/semantic_version/base.py b/semantic_version/base.py
index 0049bb5..9b517ca 100644
--- a/semantic_version/base.py
+++ b/semantic_version/base.py
@@ -1183,6 +1183,200 @@ class Spec(BaseSpec):
return Range(Range.OP_LTE, target)
+@BaseSpec.register_syntax
+class NpmSpec(BaseSpec):
+ SYNTAX = 'npm'
+
+ @classmethod
+ def _parse_to_clause(cls, expression):
+ return cls.Parser.parse(expression)
+
+ class Parser:
+ JOINER = '||'
+ HYPHEN = ' - '
+
+ NUMBER = r'x|X|\*|0|[1-9][0-9]*'
+ PART = r'[a-zA-Z0-9.-]*'
+ NPM_SPEC_BLOCK = re.compile(r"""
+ ^(?:v)? # Strip optional initial v
+ (?P<op><|<=|>=|>|=|^|~|) # Operator, can be empty
+ (?P<major>{nb})(?:\.(?P<minor>{nb})(?:\.(?P<patch>{nb}))?)?
+ (?:-(?P<prerel>{part}))? # Optional re-release
+ (?:\+(?P<build>{part}))? # Optional build
+ $""".format(nb=NUMBER, part=PART),
+ re.VERBOSE,
+ )
+
+ @classmethod
+ def range(cls, operator, target):
+ return Range(operator, target, prerelease_policy=Range.PRERELEASE_SAMEPATCH)
+
+ @classmethod
+ def parse(cls, expression):
+ result = Never()
+ groups = expression.split(cls.JOINER)
+ for group in groups:
+ group = group.strip()
+ if not group:
+ group = '>=0.0.0'
+
+ subclauses = []
+ if cls.HYPHEN in group:
+ low, high = group.split(cls.HYPHEN, 2)
+ subclauses = cls.parse_simple('>=' + low) + cls.parse_simple('<=' + high)
+ else:
+ blocks = group.split(' ')
+ for block in blocks:
+ if not cls.NPM_SPEC_BLOCK.match(block):
+ raise ValueError("Invalid NPM block in %r: %r" % (expression, block))
+
+ subclauses.extend(cls.parse_simple(block))
+
+ prerelease_clauses = []
+ non_prerel_clauses = []
+ for clause in subclauses:
+ if clause.target.prerelease:
+ if clause.operator in (Range.OP_GT, Range.OP_GTE):
+ prerelease_clauses.append(Range(
+ operator=Range.OP_LT,
+ target=Version(
+ major=clause.target.major,
+ minor=clause.target.minor,
+ patch=clause.target.patch + 1,
+ ),
+ prerelease_policy=Range.PRERELEASE_ALWAYS,
+ ))
+ elif clause.operator in (Range.OP_LT, Range.OP_LTE):
+ prerelease_clauses.append(cls.range(
+ operator=Range.OP_GTE,
+ target=Version(
+ major=clause.target.major,
+ minor=clause.target.minor,
+ patch=0,
+ prerelease=(),
+ ),
+ ))
+ prerelease_clauses.append(clause)
+ non_prerel_clauses.append(cls.range(
+ operator=clause.operator,
+ target=clause.target.truncate(),
+ ))
+ else:
+ non_prerel_clauses.append(clause)
+ if prerelease_clauses:
+ result |= AllOf(*prerelease_clauses)
+ result |= AllOf(*non_prerel_clauses)
+
+ return result
+ PREFIX_CARET = '^'
+ PREFIX_TILDE = '~'
+ PREFIX_EQ = '='
+ PREFIX_GT = '>'
+ PREFIX_GTE = '>='
+ PREFIX_LT = '<'
+ PREFIX_LTE = '<='
+ PREFIX_ALIASES = {
+ '': PREFIX_EQ,
+ }
+
+ PREFIX_TO_OPERATOR = {
+ PREFIX_EQ: Range.OP_EQ,
+ PREFIX_LT: Range.OP_LT,
+ PREFIX_LTE: Range.OP_LTE,
+ PREFIX_GTE: Range.OP_GTE,
+ PREFIX_GT: Range.OP_GT,
+ }
+
+ EMPTY_VALUES = ['*', 'x', 'X', None]
+
+ @classmethod
+ def parse_simple(cls, simple):
+ match = cls.NPM_SPEC_BLOCK.match(simple)
+
+ prefix, major_t, minor_t, patch_t, prerel, build = match.groups()
+
+ prefix = cls.PREFIX_ALIASES.get(prefix, prefix)
+ major = None if major_t in cls.EMPTY_VALUES else int(major_t)
+ minor = None if minor_t in cls.EMPTY_VALUES else int(minor_t)
+ patch = None if patch_t in cls.EMPTY_VALUES else int(patch_t)
+
+ if build is not None and prefix not in [cls.PREFIX_EQ]:
+ # Ignore the 'build' part when not comparing to a specific part.
+ build = None
+
+ if major is None: # '*', 'x', 'X'
+ target = Version(major=0, minor=0, patch=0)
+ if prefix not in [cls.PREFIX_EQ, cls.PREFIX_GTE]:
+ raise ValueError("Invalid expression %r" % simple)
+ prefix = cls.PREFIX_GTE
+ elif minor is None:
+ target = Version(major=major, minor=0, patch=0)
+ elif patch is None:
+ target = Version(major=major, minor=minor, patch=0)
+ else:
+ target = Version(
+ major=major,
+ minor=minor,
+ patch=patch,
+ prerelease=prerel.split('.') if prerel else (),
+ build=build.split('.') if build else (),
+ )
+
+ if (major is None or minor is None or patch is None) and (prerel or build):
+ raise ValueError("Invalid NPM spec: %r" % simple)
+
+ if prefix == cls.PREFIX_CARET:
+ if target.major: # ^1.2.4 => >=1.2.4 <2.0.0
+ high = target.next_major()
+ elif target.minor: # ^0.1.2 => >=0.1.2 <0.2.0
+ high = target.next_minor()
+ else: # ^0.0.1 => >=0.0.1 <0.0.2
+ high = target.next_patch()
+ return [cls.range(Range.OP_GTE, target), cls.Range(Range.OP_LT, high)]
+
+ elif prefix == cls.PREFIX_TILDE:
+ assert major is not None
+ if minor is None: # ~1.x => >=1.0.0 <2.0.0
+ high = target.next_major()
+ else: # ~1.2.x => >=1.2.0 <1.3.0; ~1.2.3 => >=1.2.3 <1.3.0
+ high = target.next_minor()
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, high)]
+
+ elif prefix == cls.PREFIX_EQ:
+ if major is None:
+ return [cls.range(Range.OP_GTE, target)]
+ elif minor is None:
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_major())]
+ elif patch is None:
+ return [cls.range(Range.OP_GTE, target), cls.range(Range.OP_LT, target.next_minor())]
+ else:
+ return [cls.range(Range.OP_EQ, target)]
+
+ elif prefix == cls.PREFIX_GT:
+ assert major is not None
+ if minor is None: # >1.x
+ return [cls.range(Range.OP_GTE, target.next_major())]
+ elif patch is None: # >1.2.x => >=1.3.0
+ return [cls.range(Range.OP_GTE, target.next_minor())]
+ else:
+ return [cls.range(Range.OP_GT, target)]
+
+ elif prefix == cls.PREFIX_GTE:
+ return [cls.range(Range.OP_GTE, target)]
+
+ elif prefix == cls.PREFIX_LT:
+ assert major is not None
+ return [cls.range(Range.OP_LT, target)]
+
+ else:
+ assert prefix == cls.PREFIX_LTE
+ assert major is not None
+ if minor is None: # <=1.x => <2.0.0
+ return [cls.range(Range.OP_LT, target.next_major())]
+ elif patch is None: # <=1.2.x => <1.3.0
+ return [cls.range(Range.OP_LT, target.next_minor())]
+ else:
+ return [cls.range(Range.OP_LTE, target)]
diff --git a/tests/django_test_app/models.py b/tests/django_test_app/models.py
index 3a43cfc..5f790b4 100644
--- a/tests/django_test_app/models.py
+++ b/tests/django_test_app/models.py
@@ -14,6 +14,7 @@ if django_loaded:
class VersionModel(models.Model):
version = semver_fields.VersionField(verbose_name='my version')
spec = semver_fields.SpecField(verbose_name='my spec')
+ npm_spec = semver_fields.SpecField(syntax='npm', blank=True, verbose_name='npm spec')
class PartialVersionModel(models.Model):
partial = semver_fields.VersionField(partial=True, verbose_name='partial version')
diff --git a/tests/test_django.py b/tests/test_django.py
index b5c4a9c..5fff8a9 100644
--- a/tests/test_django.py
+++ b/tests/test_django.py
@@ -4,7 +4,7 @@
import unittest
-from semantic_version import Version, NativeSpec
+from semantic_version import Version, SimpleSpec, NpmSpec
from .setup_django import django_loaded
@@ -64,25 +64,29 @@ class DjangoFieldTestCase(unittest.TestCase):
obj = models.VersionModel(
version=Version('0.1.1'),
spec=SimpleSpec('==0.1.1,!=0.1.1-alpha'),
+ npm_spec=NpmSpec('1.2 - 2.3'),
)
self.assertEqual(Version('0.1.1'), obj.version)
self.assertEqual(SimpleSpec('==0.1.1,!=0.1.1-alpha'), obj.spec)
+ self.assertEqual(NpmSpec('1.2 - 2.3'), obj.npm_spec)
- alt_obj = models.VersionModel(version=obj.version, spec=obj.spec)
+ alt_obj = models.VersionModel(version=obj.version, spec=obj.spec, npm_spec=obj.npm_spec)
self.assertEqual(Version('0.1.1'), alt_obj.version)
self.assertEqual(SimpleSpec('==0.1.1,!=0.1.1-alpha'), alt_obj.spec)
self.assertEqual(obj.spec, alt_obj.spec)
+ self.assertEqual(obj.npm_spec, alt_obj.npm_spec)
self.assertEqual(obj.version, alt_obj.version)
def test_version_clean(self):
"""Calling .full_clean() should convert str to Version/Spec objects."""
- obj = models.VersionModel(version='0.1.1', spec='==0.1.1,!=0.1.1-alpha')
+ obj = models.VersionModel(version='0.1.1', spec='==0.1.1,!=0.1.1-alpha', npm_spec='1.x')
obj.full_clean()
self.assertEqual(Version('0.1.1'), obj.version)
self.assertEqual(SimpleSpec('==0.1.1,!=0.1.1-alpha'), obj.spec)
+ self.assertEqual(NpmSpec('1.x'), obj.npm_spec)
def test_version_save(self):
"""Test saving object with a VersionField."""
@@ -166,10 +170,12 @@ class DjangoFieldTestCase(unittest.TestCase):
o1 = models.VersionModel(
version=Version('0.1.1'),
spec=SimpleSpec('==0.1.1,!=0.1.1-alpha'),
+ npm_spec=NpmSpec('1.2 - 2.3'),
)
o2 = models.VersionModel(
version=Version('0.4.3-rc3+build3'),
spec=SimpleSpec('<=0.1.1-rc2,!=0.1.1-rc1'),
+ npm_spec=NpmSpec('1.2 - 2.3'),
)
data = serializers.serialize('json', [o1, o2])
@@ -177,8 +183,10 @@ class DjangoFieldTestCase(unittest.TestCase):
obj1, obj2 = serializers.deserialize('json', data)
self.assertEqual(o1.version, obj1.object.version)
self.assertEqual(o1.spec, obj1.object.spec)
+ self.assertEqual(o1.npm_spec, obj1.object.npm_spec)
self.assertEqual(o2.version, obj2.object.version)
self.assertEqual(o2.spec, obj2.object.spec)
+ self.assertEqual(o2.npm_spec, obj2.object.npm_spec)
def test_serialization_partial(self):
o1 = models.PartialVersionModel(
@@ -220,6 +228,11 @@ class FieldMigrationTests(DjangoTestCase):
expected = {'max_length': 200}
self.assertEqual(field.deconstruct()[3], expected)
+ def test_nondefault_spec_field(self):
+ field = django_fields.SpecField(syntax='npm')
+ expected = {'max_length': 200, 'syntax': 'npm'}
+ self.assertEqual(field.deconstruct()[3], expected)
+
@unittest.skipIf(not django_loaded, "Django not installed")
class FullMigrateTests(TransactionTestCase):
diff --git a/tests/test_npm.py b/tests/test_npm.py
new file mode 100644
index 0000000..76cb6e2
--- /dev/null
+++ b/tests/test_npm.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) The python-semanticversion project
+# This code is distributed under the two-clause BSD License.
+
+"""Test NPM-style specifications."""
+
+import unittest
+
+from semantic_version import base
+
+
+class NpmSpecTests(unittest.TestCase):
+ examples = {
+ # range: [matchings], [failings]
+ '>=1.2.7': (
+ ['1.2.7', '1.2.8', '1.3.9'],
+ ['1.2.6', '1.1.0'],
+ ),
+ '>=1.2.7 <1.3.0': (
+ ['1.2.7', '1.2.8', '1.2.99'],
+ ['1.2.6', '1.3.0', '1.1.0'],
+ ),
+ '1.2.7 || >=1.2.9 <2.0.0': (
+ ['1.2.7', '1.2.9', '1.4.6'],
+ ['1.2.8', '2.0.0'],
+ ),
+ '>1.2.3-alpha.3': (
+ ['1.2.3-alpha.7', '3.4.5'],
+ ['1.2.3-alpha.3', '3.4.5-alpha.9'],
+ ),
+ '>=1.2.3-alpha.3': (
+ ['1.2.3-alpha.3', '1.2.3-alpha.7', '3.4.5'],
+ ['1.2.3-alpha.2', '3.4.5-alpha.9'],
+ ),
+ '1.2.3 - 2.3.4': (
+ ['1.2.3', '1.2.99', '2.2.0', '2.3.4', '2.3.4+b42'],
+ ['1.2.0', '1.2.3-alpha.1', '2.3.5'],
+ ),
+ '~1.2.3-beta.2': (
+ ['1.2.3-beta.2', '1.2.3-beta.4', '1.2.4'],
+ ['1.2.4-beta.2', '1.3.0'],
+ ),
+ }
+
+ def test_spec(self):
+ for spec, lists in self.examples.items():
+ matching, failing = lists
+ for version in matching:
+ with self.subTest(spec=spec, version=version):
+ self.assertIn(base.Version(version), base.NpmSpec(spec))
+ for version in failing:
+ with self.subTest(spec=spec, version=version):
+ self.assertNotIn(base.Version(version), base.NpmSpec(spec))
+
+ expansions = {
+ # Hyphen ranges
+ '1.2.3 - 2.3.4': '>=1.2.3 <=2.3.4',
+ '1.2 - 2.3.4': '>=1.2.0 <=2.3.4',
+ '1.2.3 - 2.3': '>=1.2.3 <2.4.0',
+ '1.2.3 - 2': '>=1.2.3 <3',
+
+ # X-Ranges
+ '*': '>=0.0.0',
+ '1.x': '>=1.0.0 <2.0.0',
+ '1.2.x': '>=1.2.0 <1.3.0',
+ '': '*',
+ '1': '1.x.x',
+ '1.x.x': '>=1.0.0 <2.0.0',
+ '1.2': '1.2.x',
+
+ # Tilde ranges
+ '~1.2.3': '>=1.2.3 <1.3.0',
+ '~1.2': '>=1.2.0 <1.3.0',
+ '~1': '>=1.0.0 <2.0.0',
+ '~0.2.3': '>=0.2.3 <0.3.0',
+ '~0.2': '>=0.2.0 <0.3.0',
+ '~0': '>=0.0.0 <1.0.0',
+ '~1.2.3-beta.2': '>=1.2.3-beta.2 <1.3.0',
+ }
+
+ def test_expand(self):
+ for source, expanded in self.expansions.items():
+ with self.subTest(source=source):
+ self.assertEqual(
+ base.NpmSpec(source).clause,
+ base.NpmSpec(expanded).clause,
+ )