From 5b9174aedaf9843ee5b3b6358461910e328e74d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Barrois?= Date: Fri, 23 Aug 2019 23:11:00 +0200 Subject: Refactor spec/version matching. Instead of choosing the comparison on each `.match()` call, the expression is converted to a combination of `Range()` expressions (simple comparison to a semver-compliant `Version`). `Range()` objects can be combined with `And` and `Or` through the `AnyOf` and `AllOf` clauses (sticking to Python's naming scheme). Some specific flags have been provided to those range, allowing users to subtly alter the matching behaviour - thus accomodating different versioning schemes: - `<0.1.2` won't match `0.1.2-rc1`, unless the prerelease_policy flag is set to either `always` or `same-patch` - `<0.1.2` will match `0.1.1-rc1`, unless the `prerelease_policy` flag is set to `same-patch` - `==0.1.2` will always match `0.1.2+build44`, unless the `build_policy` is set to `strict`. The `Spec` item has been updated, alongside `SpecItem`. Those objects keep the original expression as attributes, but don't use them for comparisons. --- semantic_version/__init__.py | 2 +- semantic_version/base.py | 633 ++++++++++++++++++++++++++++++++++---- semantic_version/django_fields.py | 15 +- 3 files changed, 582 insertions(+), 68 deletions(-) (limited to 'semantic_version') diff --git a/semantic_version/__init__.py b/semantic_version/__init__.py index 6e63df7..ca30919 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, Spec, SpecItem, Version +from .base import compare, match, validate, NativeSpec, Spec, SpecItem, Version __author__ = "Raphaƫl Barrois " diff --git a/semantic_version/base.py b/semantic_version/base.py index 1caffa5..0049bb5 100644 --- a/semantic_version/base.py +++ b/semantic_version/base.py @@ -98,9 +98,9 @@ class Version: major, minor, patch, prerelease, build = self.parse(version_string, partial) else: # Convenience: allow to omit prerelease/build. + prerelease = tuple(prerelease or ()) if not partial: - prerelease = prerelease or () - build = build or () + build = tuple(build or ()) self._validate_kwargs(major, minor, patch, prerelease, build, partial) self.major = major @@ -538,6 +538,7 @@ class SpecItem: kind, spec = self.parse(requirement_string) self.kind = kind self.spec = spec + self._clause = Spec(requirement_string).clause @classmethod def parse(cls, requirement_string): @@ -564,43 +565,17 @@ class SpecItem: ) return (kind, spec) + @classmethod + def from_matcher(cls, matcher): + if matcher == Always(): + return cls('*') + elif matcher == Never(): + return cls('<0.0.0-') + elif isinstance(matcher, Range): + return cls('%s%s' % (matcher.operator, matcher.target)) + def match(self, version): - if self.kind == self.KIND_ANY: - return True - elif self.kind == self.KIND_LT: - if version.prerelease and self.spec.prerelease is None: - version = Version(major=version.major, minor=version.minor, patch=version.patch) - return version < self.spec - elif self.kind == self.KIND_LTE: - return version <= self.spec - elif self.kind == self.KIND_EQUAL: - return version == self.spec - elif self.kind == self.KIND_GTE: - return version >= self.spec - elif self.kind == self.KIND_GT: - return version > self.spec - elif self.kind == self.KIND_NEQ: - if version.prerelease and version.truncate() == self.spec.truncate() and self.spec.prerelease is None: - return False - return version != self.spec - elif self.kind == self.KIND_CARET: - if self.spec.major != 0: - upper = self.spec.next_major() - elif self.spec.minor != 0: - upper = self.spec.next_minor() - else: - upper = self.spec.next_patch() - return self.spec <= version < upper - elif self.kind == self.KIND_TILDE: - return self.spec <= version < self.spec.next_minor() - elif self.kind == self.KIND_COMPATIBLE: - if self.spec.patch is not None: - upper = self.spec.next_minor() - else: - upper = self.spec.next_major() - return self.spec <= version < upper - else: # pragma: no cover - raise ValueError('Unexpected match kind: %r' % self.kind) + return self._clause.match(version) def __str__(self): return '%s%s' % (self.kind, self.spec) @@ -617,19 +592,69 @@ class SpecItem: return hash((self.kind, self.spec)) -class Spec: - def __init__(self, *specs_strings): - subspecs = [self.parse(spec) for spec in specs_strings] - self.specs = sum(subspecs, ()) +def compare(v1, v2): + return base_cmp(Version(v1), Version(v2)) + + +def match(spec, version): + return Spec(spec).match(Version(version)) + + +def validate(version_string): + """Validates a version string againt the SemVer specification.""" + try: + Version.parse(version_string) + return True + except ValueError: + return False + + +DEFAULT_SYNTAX = 'simple' + + +class BaseSpec: + """A specification of compatible versions. + + Usage: + >>> Spec('>=1.0.0', syntax='npm') + + A version matches a specification if it matches any + of the clauses of that specification. + + Internally, a Spec is AnyOf( + AllOf(Matcher, Matcher, Matcher), + AllOf(...), + ) + """ + SYNTAXES = {} @classmethod - def parse(self, specs_string): - spec_texts = specs_string.split(',') - return tuple(SpecItem(spec_text) for spec_text in spec_texts) + def register_syntax(cls, subclass): + syntax = subclass.SYNTAX + if syntax is None: + raise ValueError("A Spec needs its SYNTAX field to be set.") + elif syntax in cls.SYNTAXES: + raise ValueError( + "Duplicate syntax for %s: %r, %r" + % (syntax, cls.SYNTAXES[syntax], subclass) + ) + cls.SYNTAXES[syntax] = subclass + return subclass - def match(self, version): - """Check whether a Version satisfies the Spec.""" - return all(spec.match(version) for spec in self.specs) + def __init__(self, expression): + super().__init__() + self.expression = expression + self.clause = self._parse_to_clause(expression) + + @classmethod + def parse(cls, expression, syntax=DEFAULT_SYNTAX): + """Convert a syntax-specific expression into a BaseSpec instance.""" + return cls.SYNTAXES[syntax](expression) + + @classmethod + def _parse_to_clause(cls, expression): + """Converts an expression to a clause.""" + raise NotImplementedError() def filter(self, versions): """Filter an iterable of versions satisfying the Spec.""" @@ -637,6 +662,10 @@ class Spec: if self.match(version): yield version + def match(self, version): + """Check whether a Version satisfies the Spec.""" + return self.clause.match(version) + def select(self, versions): """Select the best compatible version among an iterable of options.""" options = list(self.filter(versions)) @@ -649,37 +678,511 @@ class Spec: return self.match(version) return False - def __iter__(self): - return iter(self.specs) + def __eq__(self, other): + if not isinstance(other, self.__class__): + return NotImplemented + + return self.clause == other.clause + + def __hash__(self): + return hash(self.clause) def __str__(self): - return ','.join(str(spec) for spec in self.specs) + return self.expression def __repr__(self): - return '' % (self.specs,) + return '<%s: %r>' % (self.__class__.__name__, self.expression) + + +class Clause: + __slots__ = [] + + def match(self, version): + raise NotImplementedError() + + def __and__(self, other): + raise NotImplementedError() + + def __or__(self, other): + raise NotImplementedError() def __eq__(self, other): - if not isinstance(other, Spec): + raise NotImplementedError() + + def __ne__(self, other): + return not self == other + + def simplify(self): + return self + + +class AnyOf(Clause): + __slots__ = ['clauses'] + + def __init__(self, *clauses): + super().__init__() + self.clauses = frozenset(clauses) + + def match(self, version): + return any(c.match(version) for c in self.clauses) + + def simplify(self): + subclauses = set() + for clause in self.clauses: + simplified = clause.simplify() + if isinstance(simplified, AnyOf): + subclauses |= simplified.clauses + elif simplified == Never(): + continue + else: + subclauses.add(simplified) + if len(subclauses) == 1: + return subclauses.pop() + return AnyOf(*subclauses) + + def __hash__(self): + return hash((AnyOf, self.clauses)) + + def __iter__(self): + return iter(self.clauses) + + def __eq__(self, other): + return isinstance(other, self.__class__) and self.clauses == other.clauses + + def __and__(self, other): + if isinstance(other, AllOf): + return other & self + elif isinstance(other, Matcher) or isinstance(other, AnyOf): + return AllOf(self, other) + else: return NotImplemented - return set(self.specs) == set(other.specs) + def __or__(self, other): + if isinstance(other, AnyOf): + clauses = list(self.clauses | other.clauses) + elif isinstance(other, Matcher) or isinstance(other, AllOf): + clauses = list(self.clauses | set([other])) + else: + return NotImplemented + return AnyOf(*clauses) + + def __repr__(self): + return 'AnyOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) + + +class AllOf(Clause): + __slots__ = ['clauses'] + + def __init__(self, *clauses): + super().__init__() + self.clauses = frozenset(clauses) + + def match(self, version): + return all(clause.match(version) for clause in self.clauses) + + def simplify(self): + subclauses = set() + for clause in self.clauses: + simplified = clause.simplify() + if isinstance(simplified, AllOf): + subclauses |= simplified.clauses + elif simplified == Always(): + continue + else: + subclauses.add(simplified) + if len(subclauses) == 1: + return subclauses.pop() + return AllOf(*subclauses) def __hash__(self): - return hash(self.specs) + return hash((AllOf, self.clauses)) + def __iter__(self): + return iter(self.clauses) -def compare(v1, v2): - return base_cmp(Version(v1), Version(v2)) + def __eq__(self, other): + return isinstance(other, self.__class__) and self.clauses == other.clauses + def __and__(self, other): + if isinstance(other, Matcher) or isinstance(other, AnyOf): + clauses = list(self.clauses | set([other])) + elif isinstance(other, AllOf): + clauses = list(self.clauses | other.clauses) + else: + return NotImplemented + return AllOf(*clauses) + + def __or__(self, other): + if isinstance(other, AnyOf): + return other | self + elif isinstance(other, Matcher): + return AnyOf(self, AllOf(other)) + elif isinstance(other, AllOf): + return AnyOf(self, other) + else: + return NotImplemented -def match(spec, version): - return Spec(spec).match(Version(version)) + def __repr__(self): + return 'AllOf(%s)' % ', '.join(sorted(repr(c) for c in self.clauses)) -def validate(version_string): - """Validates a version string againt the SemVer specification.""" - try: - Version.parse(version_string) - return True - except ValueError: +class Matcher(Clause): + __slots__ = [] + + def __and__(self, other): + if isinstance(other, AllOf): + return other & self + elif isinstance(other, Matcher) or isinstance(other, AnyOf): + return AllOf(self, other) + else: + return NotImplemented + + def __or__(self, other): + if isinstance(other, AnyOf): + return other | self + elif isinstance(other, Matcher) or isinstance(other, AllOf): + return AnyOf(self, other) + else: + return NotImplemented + + +class Never(Matcher): + __slots__ = [] + + def match(self, version): return False + + def __hash__(self): + return hash((Never,)) + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __and__(self, other): + return self + + def __or__(self, other): + return other + + def __repr__(self): + return 'Never()' + + +class Always(Matcher): + __slots__ = [] + + def match(self, version): + return True + + def __hash__(self): + return hash((Always,)) + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __and__(self, other): + return other + + def __or__(self, other): + return self + + def __repr__(self): + return 'Always()' + + +class Range(Matcher): + OP_EQ = '==' + OP_GT = '>' + OP_GTE = '>=' + OP_LT = '<' + OP_LTE = '<=' + OP_NEQ = '!=' + + # <1.2.3 matches 1.2.3-a1 + PRERELEASE_ALWAYS = 'always' + # <1.2.3 does not match 1.2.3-a1 + PRERELEASE_NATURAL = 'natural' + # 1.2.3-a1 is only considered if target == 1.2.3-xxx + PRERELEASE_SAMEPATCH = 'same-patch' + + # 1.2.3 matches 1.2.3+* + BUILD_IMPLICIT = 'implicit' + # 1.2.3 matches only 1.2.3, not 1.2.3+4 + BUILD_STRICT = 'strict' + + __slots__ = ['operator', 'target', 'prerelease_policy', 'build_policy'] + + def __init__(self, operator, target, prerelease_policy=PRERELEASE_NATURAL, build_policy=BUILD_IMPLICIT): + super().__init__() + if target.build and operator not in (self.OP_EQ, self.OP_NEQ): + raise ValueError( + "Invalid range %s%s: build numbers have no ordering." + % (operator, target)) + self.operator = operator + self.target = target + self.prerelease_policy = prerelease_policy + self.build_policy = self.BUILD_STRICT if target.build else build_policy + + def match(self, version): + if self.build_policy != self.BUILD_STRICT: + version = version.truncate('prerelease') + + if version.prerelease: + same_patch = self.target.truncate() == version.truncate() + + if self.prerelease_policy == self.PRERELEASE_SAMEPATCH and not same_patch: + return False + + if self.operator == self.OP_EQ: + if self.build_policy == self.BUILD_STRICT: + return ( + self.target.truncate('prerelease') == version.truncate('prerelease') + and version.build == self.target.build + ) + return version == self.target + elif self.operator == self.OP_GT: + return version > self.target + elif self.operator == self.OP_GTE: + return version >= self.target + elif self.operator == self.OP_LT: + if ( + version.prerelease + and self.prerelease_policy == self.PRERELEASE_NATURAL + and version.truncate() == self.target.truncate() + and not self.target.prerelease + ): + return False + return version < self.target + elif self.operator == self.OP_LTE: + return version <= self.target + else: + assert self.operator == self.OP_NEQ + if self.build_policy == self.BUILD_STRICT: + return not ( + self.target.truncate('prerelease') == version.truncate('prerelease') + and version.build == self.target.build + ) + + if ( + version.prerelease + and self.prerelease_policy == self.PRERELEASE_NATURAL + and version.truncate() == self.target.truncate() + and not self.target.prerelease + ): + return False + return version != self.target + + def __hash__(self): + return hash((Range, self.operator, self.target, self.prerelease_policy)) + + def __eq__(self, other): + return ( + isinstance(other, self.__class__) + and self.operator == other.operator + and self.target == other.target + and self.prerelease_policy == other.prerelease_policy + ) + + def __str__(self): + return '%s%s' % (self.operator, self.target) + + def __repr__(self): + policy_part = ( + '' if self.prerelease_policy == self.PRERELEASE_NATURAL + else ', prerelease_policy=%r' % self.prerelease_policy + ) + ( + '' if self.build_policy == self.BUILD_IMPLICIT + else ', build_policy=%r' % self.build_policy + ) + return 'Range(%r, %r%s)' % ( + self.operator, + self.target, + policy_part, + ) + + +@BaseSpec.register_syntax +class Spec(BaseSpec): + + SYNTAX = 'simple' + + def __init__(self, expression, *legacy_extras): + expression = ','.join((expression,) + legacy_extras) + super().__init__(expression) + + def __iter__(self): + for clause in self.clause: + yield SpecItem.from_matcher(clause) + + @classmethod + def _parse_to_clause(cls, expression): + return cls.Parser.parse(expression) + + class Parser: + NUMBER = r'\*|0|[1-9][0-9]*' + NAIVE_SPEC = re.compile(r"""^ + (?P<|<=||=|==|>=|>|!=|\^|~|~=) + (?P{nb})(?:\.(?P{nb})(?:\.(?P{nb}))?)? + (?:-(?P[a-z0-9A-Z.-]*))? + (?:\+(?P[a-z0-9A-Z.-]*))? + $ + """.format(nb=NUMBER), + re.VERBOSE, + ) + + @classmethod + def parse(cls, expression): + blocks = expression.split(',') + clause = Always() + for block in blocks: + if not cls.NAIVE_SPEC.match(block): + raise ValueError("Invalid simple block %r" % block) + clause &= cls.parse_block(block) + + return clause + + PREFIX_CARET = '^' + PREFIX_TILDE = '~' + PREFIX_COMPATIBLE = '~=' + PREFIX_EQ = '==' + PREFIX_NEQ = '!=' + PREFIX_GT = '>' + PREFIX_GTE = '>=' + PREFIX_LT = '<' + PREFIX_LTE = '<=' + + PREFIX_ALIASES = { + '=': PREFIX_EQ, + '': PREFIX_EQ, + } + + EMPTY_VALUES = ['*', 'x', 'X', None] + + @classmethod + def parse_block(cls, expr): + if not cls.NAIVE_SPEC.match(expr): + raise ValueError("Invalid simple spec component: %r" % expr) + prefix, major_t, minor_t, patch_t, prerel, build = cls.NAIVE_SPEC.match(expr).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 major is None: # '*' + target = Version(major=0, minor=0, patch=0) + if prefix not in (cls.PREFIX_EQ, cls.PREFIX_GTE): + raise ValueError("Invalid simple spec: %r" % expr) + 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 simple spec: %r" % expr) + + if build is not None and prefix not in (cls.PREFIX_EQ, cls.PREFIX_NEQ): + raise ValueError("Invalid simple spec: %r" % expr) + + if prefix == cls.PREFIX_CARET: + # Accept anything with the same most-significant digit + if target.major: + high = target.next_major() + elif target.minor: + high = target.next_minor() + else: + high = target.next_patch() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_TILDE: + assert major is not None + # Accept any higher patch in the same minor + # Might go higher if the initial version was a partial + if minor is None: + high = target.next_major() + else: + high = target.next_minor() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_COMPATIBLE: + assert major is not None + # ~1 is 1.0.0..2.0.0; ~=2.2 is 2.2.0..3.0.0; ~=1.4.5 is 1.4.5..1.5.0 + if minor is None or patch is None: + # We got a partial version + high = target.next_major() + else: + high = target.next_minor() + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, high) + + elif prefix == cls.PREFIX_EQ: + if major is None: + return Range(Range.OP_GTE, target) + elif minor is None: + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_major()) + elif patch is None: + return Range(Range.OP_GTE, target) & Range(Range.OP_LT, target.next_patch()) + elif build == '': + return Range(Range.OP_EQ, target, build_policy=Range.BUILD_STRICT) + else: + return Range(Range.OP_EQ, target) + + elif prefix == cls.PREFIX_NEQ: + assert major is not None + if minor is None: + # !=1.x => <1.0.0 || >=2.0.0 + return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_major()) + elif patch is None: + # !=1.2.x => <1.2.0 || >=1.3.0 + return Range(Range.OP_LT, target) | Range(Range.OP_GTE, target.next_minor()) + elif prerel == '': + # !=1.2.3- + return Range(Range.OP_NEQ, target, prerelease_policy=Range.PRERELEASE_ALWAYS) + elif build == '': + # !=1.2.3+ or !=1.2.3-a2+ + return Range(Range.OP_NEQ, target, build_policy=Range.BUILD_STRICT) + else: + return Range(Range.OP_NEQ, target) + + elif prefix == cls.PREFIX_GT: + assert major is not None + if minor is None: + # >1.x => >=2.0 + return Range(Range.OP_GTE, target.next_major()) + elif patch is None: + return Range(Range.OP_GTE, target.next_minor()) + else: + return Range(Range.OP_GT, target) + + elif prefix == cls.PREFIX_GTE: + return Range(Range.OP_GTE, target) + + elif prefix == cls.PREFIX_LT: + assert major is not None + if prerel == '': + # <1.2.3- + return Range(Range.OP_LT, target, prerelease_policy=Range.PRERELEASE_ALWAYS) + return Range(Range.OP_LT, target) + + else: + assert prefix == cls.PREFIX_LTE + assert major is not None + if minor is None: + # <=1.x => <2.0 + return Range(Range.OP_LT, target.next_major()) + elif patch is None: + return Range(Range.OP_LT, target.next_minor()) + else: + return Range(Range.OP_LTE, target) + + + + + diff --git a/semantic_version/django_fields.py b/semantic_version/django_fields.py index 2e9be69..1af5bf5 100644 --- a/semantic_version/django_fields.py +++ b/semantic_version/django_fields.py @@ -74,10 +74,21 @@ class SpecField(SemVerField): } description = _("Version specification list") + def __init__(self, *args, **kwargs): + self.syntax = kwargs.pop('syntax', base.DEFAULT_SYNTAX) + super().__init__(*args, **kwargs) + + def deconstruct(self): + """Handle django.db.migrations.""" + name, path, args, kwargs = super().deconstruct() + if self.syntax != base.DEFAULT_SYNTAX: + kwargs['syntax'] = self.syntax + return name, path, args, kwargs + def to_python(self, value): """Converts any value to a base.Spec field.""" if value is None or value == '': return value - if isinstance(value, base.Spec): + if isinstance(value, base.BaseSpec): return value - return base.Spec(value) + return base.BaseSpec.parse(value, syntax=self.syntax) -- cgit v1.2.1