summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRaphaël Barrois <raphael.barrois@polytechnique.org>2013-03-20 02:02:26 +0100
committerRaphaël Barrois <raphael.barrois@polytechnique.org>2013-03-20 02:02:26 +0100
commit712d74f87c07c60f2e1d27b44915d5d4bb941fe7 (patch)
treec0263f7f74b56895fb6b36fd89316a61238cd795
parentf84d754af1ae86aaa9a891445d6ae5be36668a85 (diff)
downloadsemantic-version-712d74f87c07c60f2e1d27b44915d5d4bb941fe7.tar.gz
Add Version.coerce.
Some people don't use semver yet...
-rw-r--r--doc/changelog.rst6
-rw-r--r--doc/django.rst5
-rw-r--r--doc/index.rst16
-rw-r--r--doc/reference.rst31
-rw-r--r--src/semantic_version/base.py80
-rw-r--r--src/semantic_version/django_fields.py11
-rw-r--r--tests/django_test_app/models.py5
-rwxr-xr-xtests/test_base.py19
-rw-r--r--tests/test_django.py25
9 files changed, 195 insertions, 3 deletions
diff --git a/doc/changelog.rst b/doc/changelog.rst
index 67b6cde..d5fa820 100644
--- a/doc/changelog.rst
+++ b/doc/changelog.rst
@@ -9,6 +9,12 @@ ChangeLog
* `#1 <https://github.com/rbarrois/python-semanticversion/issues/1>`_: Allow partial
versions without minor or patch level
+*New:*
+
+ * Add the :meth:`Version.coerce <semantic_version.Version.coerce>` class method to
+ :class:`~semantic_version.Version` class for mapping arbitrary version strings to
+ semver.
+
2.1.2 (22/05/2012)
------------------
diff --git a/doc/django.rst b/doc/django.rst
index 8cfdbca..a43c3ed 100644
--- a/doc/django.rst
+++ b/doc/django.rst
@@ -20,6 +20,11 @@ with their :attr:`~django.db.models.CharField.max_length` defaulting to 200.
Boolean; whether :attr:`~semantic_version.Version.partial` versions are allowed.
+ .. attribute:: coerce
+
+ Boolean; whether passed in values should be coerced into a semver string
+ before storing.
+
.. class:: SpecField
diff --git a/doc/index.rst b/doc/index.rst
index 1505710..0e19004 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -136,6 +136,22 @@ It is also possible to select the 'best' version from such iterables::
>>> s.select(versions)
Version('0.3.0')
+
+Coercing an arbitrary version string
+""""""""""""""""""""""""""""""""""""
+
+Some user-supplied input might not match the semantic version scheme.
+For such cases, the :meth:`Version.coerce` method will try to convert any
+version-like string into a valid semver version::
+
+ >>> Version.coerce('0')
+ Version('0.0.0')
+ >>> Version.coerce('0.1.2.3.4')
+ Version('0.1.2+3.4')
+ >>> Version.coerce('0.1.2a3')
+ Version('0.1.2-a3')
+
+
Including pre-release identifiers in specifications
"""""""""""""""""""""""""""""""""""""""""""""""""""
diff --git a/doc/reference.rst b/doc/reference.rst
index bff0863..66a5f7d 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -188,6 +188,37 @@ Representing a version (the Version class)
:raises: :exc:`ValueError`, if the :attr:`version_string` is invalid.
:rtype: (major, minor, patch, prerelease, build)
+ .. classmethod:: coerce(cls, version_string[, partial=False])
+
+ Try to convert an arbitrary version string into a :class:`Version` instance.
+
+ Rules are:
+
+ - If no minor or patch component, and :attr:`partial` is :obj:`False`,
+ replace them with zeroes
+ - Any character outside of ``a-zA-Z0-9.+-`` is replaced with a ``-``
+ - If more than 3 dot-separated numerical components, everything from the
+ fourth component belongs to the :attr:`build` part
+ - Any extra ``+`` in the :attr:`build` part will be replaced with dots
+
+ Examples:
+
+ .. code-block:: pycon
+
+ >>> Version.coerce('02')
+ Version('2.0.0')
+ >>> Version.coerce('1.2.3.4')
+ Version('1.2.3+4')
+ >>> Version.coerce('1.2.3.4beta2')
+ Version('1.2.3+4beta2')
+ >>> Version.coerce('1.2.3.4.5_6/7+8+9+10')
+ Version('1.2.3+4.5-6-7.8.9.10')
+
+ :param str version_string: The version string to coerce
+ :param bool partial: Whether to allow generating a :attr:`partial` version
+ :raises: :exc:`ValueError`, if the :attr:`version_string` is invalid.
+ :rtype: :class:`Version`
+
Version specifications (the Spec class)
---------------------------------------
diff --git a/src/semantic_version/base.py b/src/semantic_version/base.py
index b8e7fcf..b52c671 100644
--- a/src/semantic_version/base.py
+++ b/src/semantic_version/base.py
@@ -78,7 +78,85 @@ class Version(object):
return int(value)
@classmethod
- def parse(cls, version_string, partial=False):
+ def coerce(cls, version_string, partial=False):
+ """Coerce an arbitrary version string into a semver-compatible one.
+
+ The rule is:
+ - If not enough components, fill minor/patch with zeroes; unless
+ partial=True
+ - If more than 3 dot-separated components, extra components are "build"
+ data. If some "build" data already appeared, append it to the
+ extra components
+
+ Examples:
+ >>> Version.coerce('0.1')
+ Version(0, 1, 0)
+ >>> Version.coerce('0.1.2.3')
+ Version(0, 1, 2, (), ('3',))
+ >>> Version.coerce('0.1.2.3+4')
+ Version(0, 1, 2, (), ('3', '4'))
+ >>> Version.coerce('0.1+2-3+4_5')
+ Version(0, 1, 0, (), ('2-3', '4-5'))
+ """
+ base_re = re.compile(r'^\d+(?:\.\d+(?:\.\d+)?)?')
+
+ match = base_re.match(version_string)
+ if not match:
+ raise ValueError("Version string lacks a numerical component: %r"
+ % version_string)
+
+ version = version_string[:match.end()]
+ if not partial:
+ # We need a not-partial version.
+ while version.count('.') < 2:
+ version += '.0'
+
+ if match.end() == len(version_string):
+ return Version(version, partial=partial)
+
+ rest = version_string[match.end():]
+
+ # Cleanup the 'rest'
+ rest = re.sub(r'[^a-zA-Z0-9+.-]', '-', rest)
+
+ if rest[0] == '+':
+ # A 'build' component
+ prerelease = ''
+ build = rest[1:]
+ elif rest[0] == '.':
+ # An extra version component, probably 'build'
+ prerelease = ''
+ build = rest[1:]
+ elif rest[0] == '-':
+ rest = rest[1:]
+ if '+' in rest:
+ prerelease, build = rest.split('+', 1)
+ else:
+ prerelease, build = rest, ''
+ elif '+' in rest:
+ prerelease, build = rest.split('+', 1)
+ else:
+ prerelease, build = rest, ''
+
+ build = build.replace('+', '.')
+
+ if prerelease:
+ version = '%s-%s' % (version, prerelease)
+ if build:
+ version = '%s+%s' % (version, build)
+
+ return cls(version, partial=partial)
+
+ @classmethod
+ def parse(cls, version_string, partial=False, coerce=False):
+ """Parse a version string into a Version() object.
+
+ Args:
+ version_string (str), the version string to parse
+ partial (bool), whether to accept incomplete input
+ coerce (bool), whether to try to map the passed in string into a
+ valid Version.
+ """
if not version_string:
raise ValueError('Invalid empty version string: %r' % version_string)
diff --git a/src/semantic_version/django_fields.py b/src/semantic_version/django_fields.py
index ecc0a8f..eaf668a 100644
--- a/src/semantic_version/django_fields.py
+++ b/src/semantic_version/django_fields.py
@@ -38,6 +38,7 @@ class VersionField(BaseSemVerField):
def __init__(self, *args, **kwargs):
self.partial = kwargs.pop('partial', False)
+ self.coerce = kwargs.pop('coerce', False)
super(VersionField, self).__init__(*args, **kwargs)
def to_python(self, value):
@@ -46,7 +47,10 @@ class VersionField(BaseSemVerField):
return value
if isinstance(value, base.Version):
return value
- return base.Version(value, partial=self.partial)
+ if self.coerce:
+ return base.Version.coerce(value, partial=self.partial)
+ else:
+ return base.Version(value, partial=self.partial)
class SpecField(BaseSemVerField):
@@ -71,7 +75,10 @@ def add_south_rules():
(
(VersionField,),
[],
- {'partial': ('partial', {'default': False})},
+ {
+ 'partial': ('partial', {'default': False}),
+ 'coerce': ('coerce', {'default': False}),
+ },
),
], ["semantic_version\.django_fields"])
diff --git a/tests/django_test_app/models.py b/tests/django_test_app/models.py
index 9c44a29..f36c385 100644
--- a/tests/django_test_app/models.py
+++ b/tests/django_test_app/models.py
@@ -14,3 +14,8 @@ class PartialVersionModel(models.Model):
partial = semver_fields.VersionField(partial=True, verbose_name='partial version')
optional = semver_fields.VersionField(verbose_name='optional version', blank=True, null=True)
optional_spec = semver_fields.SpecField(verbose_name='optional spec', blank=True, null=True)
+
+
+class CoerceVersionModel(models.Model):
+ version = semver_fields.VersionField(verbose_name='my version', coerce=True)
+ partial = semver_fields.VersionField(verbose_name='partial version', coerce=True, partial=True)
diff --git a/tests/test_base.py b/tests/test_base.py
index 90dbe96..3e10a83 100755
--- a/tests/test_base.py
+++ b/tests/test_base.py
@@ -301,6 +301,25 @@ class SpecItemTestCase(unittest.TestCase):
len(set([base.SpecItem('==0.1.0'), base.SpecItem('==0.1.0')])))
+class CoerceTestCase(unittest.TestCase):
+ examples = {
+ # Dict of target: [list of equivalents]
+ '0.1.0': ('0.1', '0.1+', '0.1-', '0.1.0', '0.000001.000000000000'),
+ '0.1.0+2': ('0.1.0+2', '0.1.0.2'),
+ '0.1.0+2.3.4': ('0.1.0+2.3.4', '0.1.0+2+3+4', '0.1.0.2+3+4'),
+ '0.1.0+2-3.4': ('0.1.0+2-3.4', '0.1.0+2-3+4', '0.1.0.2-3+4', '0.1.0.2_3+4'),
+ '0.1.0-a2.3': ('0.1.0-a2.3', '0.1.0a2.3', '0.1.0_a2.3'),
+ '0.1.0-a2.3+4.5-6': ('0.1.0-a2.3+4.5-6', '0.1.0a2.3+4.5-6', '0.1.0a2.3+4.5_6', '0.1.0a2.3+4+5/6'),
+ }
+
+ def test_coerce(self):
+ for equivalent, samples in self.examples.items():
+ target = base.Version(equivalent)
+ for sample in samples:
+ v_sample = base.Version.coerce(sample)
+ self.assertEqual(target, v_sample)
+
+
class SpecTestCase(unittest.TestCase):
examples = {
'>=0.1.1,<0.1.2': ['>=0.1.1', '<0.1.2'],
diff --git a/tests/test_django.py b/tests/test_django.py
index a2d4c9b..f3eac10 100644
--- a/tests/test_django.py
+++ b/tests/test_django.py
@@ -61,6 +61,15 @@ class DjangoFieldTestCase(unittest.TestCase):
self.assertEqual(semantic_version.Version('0.1.1'), obj.version)
self.assertEqual(semantic_version.Spec('==0,!=0.2'), obj.spec)
+ def test_coerce(self):
+ obj = models.CoerceVersionModel(version='0.1.1a+2', partial='23')
+ self.assertEqual(semantic_version.Version('0.1.1-a+2'), obj.version)
+ self.assertEqual(semantic_version.Version('23', partial=True), obj.partial)
+
+ obj2 = models.CoerceVersionModel(version='23', partial='0.1.2.3.4.5/6')
+ self.assertEqual(semantic_version.Version('23.0.0'), obj2.version)
+ self.assertEqual(semantic_version.Version('0.1.2+3.4.5-6', partial=True), obj2.partial)
+
def test_invalid_input(self):
self.assertRaises(ValueError, models.VersionModel,
version='0.1.1', spec='blah')
@@ -135,6 +144,15 @@ class SouthTestCase(unittest.TestCase):
self.assertEqual(frozen['optional_spec'],
('semantic_version.django_fields.SpecField', [], {'max_length': '200', 'blank': 'True', 'null': 'True'}))
+ def test_freezing_coerce_version_model(self):
+ frozen = south.modelsinspector.get_model_fields(models.CoerceVersionModel)
+
+ self.assertEqual(frozen['version'],
+ ('semantic_version.django_fields.VersionField', [], {'max_length': '200', 'coerce': 'True'}))
+
+ self.assertEqual(frozen['partial'],
+ ('semantic_version.django_fields.VersionField', [], {'max_length': '200', 'partial': 'True', 'coerce': 'True'}))
+
def test_freezing_app(self):
frozen = south.creator.freezer.freeze_apps('django_test_app')
@@ -155,6 +173,13 @@ class SouthTestCase(unittest.TestCase):
self.assertEqual(frozen['django_test_app.partialversionmodel']['optional_spec'],
('semantic_version.django_fields.SpecField', [], {'max_length': '200', 'blank': 'True', 'null': 'True'}))
+ # Test CoerceVersionModel
+ self.assertEqual(frozen['django_test_app.coerceversionmodel']['version'],
+ ('semantic_version.django_fields.VersionField', [], {'max_length': '200', 'coerce': 'True'}))
+
+ self.assertEqual(frozen['django_test_app.coerceversionmodel']['partial'],
+ ('semantic_version.django_fields.VersionField', [], {'max_length': '200', 'partial': 'True', 'coerce': 'True'}))
+
if django_loaded:
from django.test import TestCase