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. --- semantic_version/__init__.py | 2 +- semantic_version/base.py | 194 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) (limited to 'semantic_version') 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)] -- cgit v1.2.1