Add Version.coerce.
Some people don't use semver yet...
This commit is contained in:
@@ -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)
|
||||
------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
"""""""""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
|
||||
@@ -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)
|
||||
---------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user