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. --- ChangeLog | 5 + README.rst | 58 ++-- docs/django.rst | 10 +- semantic_version/__init__.py | 2 +- semantic_version/base.py | 633 ++++++++++++++++++++++++++++++++++---- semantic_version/django_fields.py | 15 +- tests/test_django.py | 43 +-- 7 files changed, 641 insertions(+), 125 deletions(-) diff --git a/ChangeLog b/ChangeLog index 687d899..169bd2a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -18,6 +18,11 @@ ChangeLog * Remove support for Python2 (End of life 4 months after this release) +*Refactor:* + + * Switch spec computation to a two-step process: convert the spec to a combination + of simple comparisons with clear semantics, then use those. + 2.6.0 (2016-09-25) ------------------ diff --git a/README.rst b/README.rst index ac42f72..0d0d421 100644 --- a/README.rst +++ b/README.rst @@ -59,10 +59,13 @@ Import it in your code: .. currentmodule:: semantic_version -This module provides two classes to handle semantic versions: +This module provides classes to handle semantic versions: - :class:`Version` represents a version number (``0.1.1-alpha+build.2012-05-15``) -- :class:`Spec` represents a requirement specification (``>=0.1.1,<0.3.0``) +- :class:`BaseSpec`-derived classes represent requirement specifications (``>=0.1.1,<0.3.0``): + + - :class:`NativeSpec` describes a natural description syntax + - :class:`NpmSpec` is used for NPM-style range descriptions. Versions -------- @@ -168,12 +171,12 @@ In that case, ``major``, ``minor`` and ``patch`` are mandatory, and must be inte Requirement specification ------------------------- -The :class:`Spec` object describes a range of accepted versions: +The :class:`NativeSpec` object describes a range of accepted versions: .. code-block:: pycon - >>> s = Spec('>=0.1.1') # At least 0.1.1 + >>> s = NativeSpec('>=0.1.1') # At least 0.1.1 >>> s.match(Version('0.1.1')) True >>> s.match(Version('0.1.1-alpha1')) # pre-release satisfy version spec @@ -185,42 +188,28 @@ Simpler test syntax is also available using the ``in`` keyword: .. code-block:: pycon - >>> s = Spec('==0.1.1') + >>> s = NativeSpec('==0.1.1') >>> Version('0.1.1-alpha1') in s True >>> Version('0.1.2') in s False -Combining specifications can be expressed in two ways: - -- Components separated by commas in a single string: - - .. code-block:: pycon - - >>> Spec('>=0.1.1,<0.3.0') - -- Components given as different arguments: +Combining specifications can be expressed as follows: .. code-block:: pycon - >>> Spec('>=0.1.1', '<0.3.0') - -- A mix of both versions: - - .. code-block:: pycon - - >>> Spec('>=0.1.1', '!=0.2.4-alpha,<0.3.0') + >>> NativeSpec('>=0.1.1,<0.3.0') Using a specification """"""""""""""""""""" -The :func:`Spec.filter` method filters an iterable of :class:`Version`: +The :func:`NativeSpec.filter` method filters an iterable of :class:`Version`: .. code-block:: pycon - >>> s = Spec('>=0.1.0,<0.4.0') + >>> s = NativeSpec('>=0.1.0,<0.4.0') >>> versions = (Version('0.%d.0' % i) for i in range(6)) >>> for v in s.filter(versions): ... print v @@ -233,7 +222,7 @@ It is also possible to select the 'best' version from such iterables: .. code-block:: pycon - >>> s = Spec('>=0.1.0,<0.4.0') + >>> s = NativeSpec('>=0.1.0,<0.4.0') >>> versions = (Version('0.%d.0' % i) for i in range(6)) >>> s.select(versions) Version('0.3.0') @@ -259,22 +248,21 @@ version-like string into a valid semver version: Including pre-release identifiers in specifications """"""""""""""""""""""""""""""""""""""""""""""""""" -When testing a :class:`Version` against a :class:`Spec`, comparisons are only -performed for components defined in the :class:`Spec`; thus, a pre-release -version (``1.0.0-alpha``), while not strictly equal to the non pre-release -version (``1.0.0``), satisfies the ``==1.0.0`` :class:`Spec`. +When testing a :class:`Version` against a :class:`NativeSpec`, comparisons are +adjusted for common user expectations; thus, a pre-release version (``1.0.0-alpha``) +will not satisfy the ``==1.0.0`` :class:`NativeSpec`. -Pre-release identifiers will only be compared if included in the :class:`Spec` +Pre-release identifiers will only be compared if included in the :class:`BaseSpec` definition or (for the empty pre-release number) if a single dash is appended (``1.0.0-``): .. code-block:: pycon - >>> Version('0.1.0-alpha') in Spec('>=0.1.0') # No pre-release identifier - True - >>> Version('0.1.0-alpha') in Spec('>=0.1.0-') # Include pre-release in checks + >>> Version('0.1.0-alpha') in NativeSpec('<0.1.0') # No pre-release identifier False + >>> Version('0.1.0-alpha') in NativeSpec('<0.1.0-') # Include pre-release in checks + True Including build metadata in specifications @@ -286,9 +274,9 @@ build metadata is equality. .. code-block:: pycon - >>> Version('1.0.0+build2') in Spec('<=1.0.0') # Build metadata ignored + >>> Version('1.0.0+build2') in NativeSpec('<=1.0.0') # Build metadata ignored True - >>> Version('1.0.0+build2') in Spec('==1.0.0+build2') # Include build in checks + >>> Version('1.0.0+build1') in NativeSpec('==1.0.0+build2') # Include build in checks False @@ -296,7 +284,7 @@ Using with Django ================= The :mod:`semantic_version.django_fields` module provides django fields to -store :class:`Version` or :class:`Spec` objects. +store :class:`Version` or :class:`BaseSpec` objects. More documentation is available in the :doc:`django` section. diff --git a/docs/django.rst b/docs/django.rst index a43c3ed..ab98e67 100644 --- a/docs/django.rst +++ b/docs/django.rst @@ -6,7 +6,7 @@ Interaction with Django The ``python-semanticversion`` package provides two custom fields for Django: - :class:`VersionField`: stores a :class:`semantic_version.Version` object -- :class:`SpecField`: stores a :class:`semantic_version.Spec` object +- :class:`SpecField`: stores a :class:`semantic_version.BaseSpec` object Those fields are :class:`django.db.models.CharField` subclasses, with their :attr:`~django.db.models.CharField.max_length` defaulting to 200. @@ -28,4 +28,10 @@ with their :attr:`~django.db.models.CharField.max_length` defaulting to 200. .. class:: SpecField - Stores a :class:`semantic_version.Spec` as its comma-separated string representation. + Stores a :class:`semantic_version.BaseSpec` as its textual representation. + + .. attribute:: syntax + + The syntax to use for the field; defaults to ``'native'``. + + .. versionaddedd:: 2.7 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) diff --git a/tests/test_django.py b/tests/test_django.py index 6f4d872..b5c4a9c 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -4,7 +4,7 @@ import unittest -import semantic_version +from semantic_version import Version, NativeSpec from .setup_django import django_loaded @@ -58,22 +58,21 @@ def save_and_refresh(obj): obj = obj.__class__.objects.get(id=obj.id) -Version = semantic_version.Version -Spec = semantic_version.Spec - - @unittest.skipIf(not django_loaded, "Django not installed") class DjangoFieldTestCase(unittest.TestCase): def test_version(self): - obj = models.VersionModel(version=Version('0.1.1'), spec=Spec('==0.1.1,!=0.1.1-alpha')) + obj = models.VersionModel( + version=Version('0.1.1'), + spec=SimpleSpec('==0.1.1,!=0.1.1-alpha'), + ) self.assertEqual(Version('0.1.1'), obj.version) - self.assertEqual(Spec('==0.1.1,!=0.1.1-alpha'), obj.spec) + self.assertEqual(SimpleSpec('==0.1.1,!=0.1.1-alpha'), obj.spec) alt_obj = models.VersionModel(version=obj.version, spec=obj.spec) self.assertEqual(Version('0.1.1'), alt_obj.version) - self.assertEqual(Spec('==0.1.1,!=0.1.1-alpha'), alt_obj.spec) + self.assertEqual(SimpleSpec('==0.1.1,!=0.1.1-alpha'), alt_obj.spec) self.assertEqual(obj.spec, alt_obj.spec) self.assertEqual(obj.version, alt_obj.version) @@ -83,7 +82,7 @@ class DjangoFieldTestCase(unittest.TestCase): obj.full_clean() self.assertEqual(Version('0.1.1'), obj.version) - self.assertEqual(Spec('==0.1.1,!=0.1.1-alpha'), obj.spec) + self.assertEqual(SimpleSpec('==0.1.1,!=0.1.1-alpha'), obj.spec) def test_version_save(self): """Test saving object with a VersionField.""" @@ -93,13 +92,13 @@ class DjangoFieldTestCase(unittest.TestCase): self.assertIsNone(obj.optional) save_and_refresh(obj) self.assertIsNotNone(obj.id) - self.assertIsNone(obj.optional_spec) + self.assertIsNone(obj.optional) # now set to something that is not null - spec = Spec('==0,!=0.2') - obj.optional_spec = spec + version = Version('1.2.3') + obj.optional = version save_and_refresh(obj) - self.assertEqual(obj.optional_spec, spec) + self.assertEqual(obj.optional, version) def test_spec_save(self): """Test saving object with a SpecField.""" @@ -112,7 +111,7 @@ class DjangoFieldTestCase(unittest.TestCase): self.assertIsNone(obj.optional_spec) # now set to something that is not null - spec = Spec('==0,!=0.2') + spec = SimpleSpec('==0,!=0.2') obj.optional_spec = spec save_and_refresh(obj) self.assertEqual(obj.optional_spec, spec) @@ -121,7 +120,7 @@ class DjangoFieldTestCase(unittest.TestCase): obj = models.VersionModel(version='0.1.1', spec='==0,!=0.2') obj.full_clean() self.assertEqual(Version('0.1.1'), obj.version) - self.assertEqual(Spec('==0,!=0.2'), obj.spec) + self.assertEqual(SimpleSpec('==0,!=0.2'), obj.spec) def test_coerce_clean(self): obj = models.CoerceVersionModel(version='0.1.1a+2', partial='23') @@ -164,10 +163,14 @@ class DjangoFieldTestCase(unittest.TestCase): obj.full_clean() def test_serialization(self): - o1 = models.VersionModel(version=Version('0.1.1'), spec=Spec('==0.1.1,!=0.1.1-alpha')) + o1 = models.VersionModel( + version=Version('0.1.1'), + spec=SimpleSpec('==0.1.1,!=0.1.1-alpha'), + ) o2 = models.VersionModel( version=Version('0.4.3-rc3+build3'), - spec=Spec('<=0.1.1-rc2,!=0.1.1-rc1')) + spec=SimpleSpec('<=0.1.1-rc2,!=0.1.1-rc1'), + ) data = serializers.serialize('json', [o1, o2]) @@ -186,7 +189,7 @@ class DjangoFieldTestCase(unittest.TestCase): o2 = models.PartialVersionModel( partial=Version('0.4.3-rc3+build3', partial=True), optional='', - optional_spec=Spec('==0.1.1,!=0.1.1-alpha'), + optional_spec=SimpleSpec('==0.1.1,!=0.1.1-alpha'), ) data = serializers.serialize('json', [o1, o2]) @@ -234,8 +237,8 @@ class FullMigrateTests(TransactionTestCase): class DbInteractingTestCase(DjangoTestCase): def test_db_interaction(self): - o1 = models.VersionModel(version=Version('0.1.1'), spec=Spec('<0.2.4-rc42')) - o2 = models.VersionModel(version=Version('0.4.3-rc3+build3'), spec=Spec('==0.4.3')) + o1 = models.VersionModel(version=Version('0.1.1'), spec=SimpleSpec('<0.2.4-rc42')) + o2 = models.VersionModel(version=Version('0.4.3-rc3+build3'), spec=SimpleSpec('==0.4.3')) o1.save() o2.save() -- cgit v1.2.1