diff options
author | Raphaël Barrois <raphael.barrois@polytechnique.org> | 2013-03-20 02:02:26 +0100 |
---|---|---|
committer | Raphaël Barrois <raphael.barrois@polytechnique.org> | 2013-03-20 02:02:26 +0100 |
commit | 712d74f87c07c60f2e1d27b44915d5d4bb941fe7 (patch) | |
tree | c0263f7f74b56895fb6b36fd89316a61238cd795 | |
parent | f84d754af1ae86aaa9a891445d6ae5be36668a85 (diff) | |
download | semantic-version-712d74f87c07c60f2e1d27b44915d5d4bb941fe7.tar.gz |
Add Version.coerce.
Some people don't use semver yet...
-rw-r--r-- | doc/changelog.rst | 6 | ||||
-rw-r--r-- | doc/django.rst | 5 | ||||
-rw-r--r-- | doc/index.rst | 16 | ||||
-rw-r--r-- | doc/reference.rst | 31 | ||||
-rw-r--r-- | src/semantic_version/base.py | 80 | ||||
-rw-r--r-- | src/semantic_version/django_fields.py | 11 | ||||
-rw-r--r-- | tests/django_test_app/models.py | 5 | ||||
-rwxr-xr-x | tests/test_base.py | 19 | ||||
-rw-r--r-- | tests/test_django.py | 25 |
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 |