From c4c6ab0e925d8cfabb68d34a10a783cb854b63a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Barrois?= Date: Fri, 23 Aug 2019 23:24:50 +0200 Subject: 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. --- ChangeLog | 1 + README.rst | 16 ++++ docs/reference.rst | 16 ++++ semantic_version/__init__.py | 2 +- semantic_version/base.py | 194 ++++++++++++++++++++++++++++++++++++++++ tests/django_test_app/models.py | 1 + tests/test_django.py | 19 +++- tests/test_npm.py | 88 ++++++++++++++++++ 8 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 tests/test_npm.py 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 " 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<|<=|>=|>|=|^|~|) # Operator, can be empty + (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? + (?:-(?P{part}))? # Optional re-release + (?:\+(?P{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, + ) -- cgit v1.2.1