summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRaphaël Barrois <raphael.barrois@polytechnique.org>2019-08-23 23:11:00 +0200
committerRaphaël Barrois <raphael.barrois@polytechnique.org>2019-08-26 21:33:28 +0200
commit5b9174aedaf9843ee5b3b6358461910e328e74d1 (patch)
tree4d32c28d3f0787bab7b73fb704cc6411bb08da9f
parent0b0f9d3f2f5ffa1afe9452ec55d394d4bb1ba190 (diff)
downloadsemantic-version-5b9174aedaf9843ee5b3b6358461910e328e74d1.tar.gz
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.
-rw-r--r--ChangeLog5
-rw-r--r--README.rst58
-rw-r--r--docs/django.rst10
-rw-r--r--semantic_version/__init__.py2
-rw-r--r--semantic_version/base.py633
-rw-r--r--semantic_version/django_fields.py15
-rw-r--r--tests/test_django.py43
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 <raphael.barrois+semver@polytechnique.org>"
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 '<Spec: %r>' % (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<op><|<=||=|==|>=|>|!=|\^|~|~=)
+ (?P<major>{nb})(?:\.(?P<minor>{nb})(?:\.(?P<patch>{nb}))?)?
+ (?:-(?P<prerel>[a-z0-9A-Z.-]*))?
+ (?:\+(?P<build>[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()