Add Version.coerce.

Some people don't use semver yet...
This commit is contained in:
Raphaël Barrois
2013-03-20 02:02:26 +01:00
parent f84d754af1
commit 712d74f87c
9 changed files with 195 additions and 3 deletions

View File

@@ -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)
------------------

View File

@@ -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

View File

@@ -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
"""""""""""""""""""""""""""""""""""""""""""""""""""

View File

@@ -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)
---------------------------------------

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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)

View File

@@ -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'],

View File

@@ -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