diff --git a/glance/common/exception.py b/glance/common/exception.py index 9498ad3572..4796fbf2ac 100644 --- a/glance/common/exception.py +++ b/glance/common/exception.py @@ -434,3 +434,7 @@ class MetadefTagNotFound(NotFound): message = _("The metadata definition tag with" " name=%(name)s was not found in" " namespace=%(namespace_name)s.") + + +class InvalidVersion(Invalid): + message = _("Version is invalid: %(reason)s") diff --git a/glance/common/semver_db.py b/glance/common/semver_db.py new file mode 100644 index 0000000000..1c076183e2 --- /dev/null +++ b/glance/common/semver_db.py @@ -0,0 +1,144 @@ +# Copyright (c) 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +import semantic_version + +from glance.common import exception +from glance import i18n + +MAX_COMPONENT_LENGTH = pow(2, 16) - 1 +MAX_NUMERIC_PRERELEASE_LENGTH = 6 + +_ = i18n._ + + +class DBVersion(object): + def __init__(self, components_long, prerelease, build): + """ + Creates a DBVersion object out of 3 component fields. This initializer + is supposed to be called from SQLAlchemy if 3 database columns are + mapped to this composite field. + + :param components_long: a 64-bit long value, containing numeric + components of the version + :param prerelease: a prerelease label of the version, optionally + preformatted with leading zeroes in numeric-only parts of the label + :param build: a build label of the version + """ + version_string = '%s.%s.%s' % _long_to_components(components_long) + if prerelease: + version_string += '-' + _strip_leading_zeroes_from_prerelease( + prerelease) + + if build: + version_string += '+' + build + self.version = semantic_version.Version(version_string) + + def __repr__(self): + return str(self.version) + + def __eq__(self, other): + return (isinstance(other, DBVersion) and + other.version == self.version) + + def __ne__(self, other): + return (not isinstance(other, DBVersion) + or self.version != other.version) + + def __composite_values__(self): + long_version = _version_to_long(self.version) + prerelease = _add_leading_zeroes_to_prerelease(self.version.prerelease) + build = '.'.join(self.version.build) if self.version.build else None + return long_version, prerelease, build + + +def parse(version_string): + version = semantic_version.Version.coerce(version_string) + return DBVersion(_version_to_long(version), + '.'.join(version.prerelease), + '.'.join(version.build)) + + +def _check_limit(value): + if value > MAX_COMPONENT_LENGTH: + reason = _("Version component is too " + "large (%d max)") % MAX_COMPONENT_LENGTH + raise exception.InvalidVersion(reason=reason) + + +def _version_to_long(version): + """ + Converts the numeric part of the semver version into the 64-bit long value + using the following logic: + + * major version is stored in first 16 bits of the value + * minor version is stored in next 16 bits + * patch version is stored in following 16 bits + * next 2 bits are used to store the flag: if the version has pre-release + label then these bits are 00, otherwise they are 11. Intermediate values + of the flag (01 and 10) are reserved for future usage. + * last 14 bits of the value are reserved fo future usage + + The numeric components of version are checked so their value do not exceed + 16 bits. + + :param version: a semantic_version.Version object + """ + _check_limit(version.major) + _check_limit(version.minor) + _check_limit(version.patch) + major = version.major << 48 + minor = version.minor << 32 + patch = version.patch << 16 + flag = 0 if version.prerelease else 2 + flag <<= 14 + return major | minor | patch | flag + + +def _long_to_components(value): + major = value >> 48 + minor = (value - (major << 48)) >> 32 + patch = (value - (major << 48) - (minor << 32)) >> 16 + return str(major), str(minor), str(patch) + + +def _add_leading_zeroes_to_prerelease(label_tuple): + if label_tuple is None: + return None + res = [] + for component in label_tuple: + if component.isdigit(): + if len(component) > MAX_NUMERIC_PRERELEASE_LENGTH: + reason = _("Prerelease numeric component is too large " + "(%d characters " + "max)") % MAX_NUMERIC_PRERELEASE_LENGTH + raise exception.InvalidVersion(reason=reason) + res.append(component.rjust(MAX_NUMERIC_PRERELEASE_LENGTH, '0')) + else: + res.append(component) + return '.'.join(res) + + +def _strip_leading_zeroes_from_prerelease(string_value): + res = [] + for component in string_value.split('.'): + if component.isdigit(): + val = component.lstrip('0') + if len(val) == 0: # Corner case: when the component is just '0' + val = '0' # it will be stripped completely, so restore it + res.append(val) + else: + res.append(component) + return '.'.join(res) diff --git a/glance/tests/unit/common/test_semver.py b/glance/tests/unit/common/test_semver.py new file mode 100644 index 0000000000..0f157dfb42 --- /dev/null +++ b/glance/tests/unit/common/test_semver.py @@ -0,0 +1,77 @@ +# Copyright (c) 2015 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from glance.common import exception +from glance.common import semver_db +from glance.tests import utils as test_utils + + +class SemVerTestCase(test_utils.BaseTestCase): + def test_long_conversion(self): + initial = '1.2.3-beta+07.17.2014' + v = semver_db.parse(initial) + l, prerelease, build = v.__composite_values__() + v2 = semver_db.DBVersion(l, prerelease, build) + self.assertEqual(initial, str(v2)) + + def test_major_comparison_as_long(self): + v1 = semver_db.parse("1.1.100") + v2 = semver_db.parse("2.0.0") + self.assertTrue(v2.__composite_values__()[0] > + v1.__composite_values__()[0]) + + def test_minor_comparison_as_long(self): + v1 = semver_db.parse("1.1.100") + v2 = semver_db.parse("2.0.0") + self.assertTrue(v2.__composite_values__()[0] > + v1.__composite_values__()[0]) + + def test_patch_comparison_as_long(self): + v1 = semver_db.parse("1.1.1") + v2 = semver_db.parse("1.1.100") + self.assertTrue(v2.__composite_values__()[0] > + v1.__composite_values__()[0]) + + def test_label_comparison_as_long(self): + v1 = semver_db.parse("1.1.1-alpha") + v2 = semver_db.parse("1.1.1") + self.assertTrue(v2.__composite_values__()[0] > + v1.__composite_values__()[0]) + + def test_label_comparison_as_string(self): + versions = [ + semver_db.parse("1.1.1-0.10.a.23.y.255").__composite_values__()[1], + semver_db.parse("1.1.1-0.10.z.23.x.255").__composite_values__()[1], + semver_db.parse("1.1.1-0.10.z.23.y.255").__composite_values__()[1], + semver_db.parse("1.1.1-0.10.z.23.y.256").__composite_values__()[1], + semver_db.parse("1.1.1-0.10.z.24.y.255").__composite_values__()[1], + semver_db.parse("1.1.1-0.11.z.24.y.255").__composite_values__()[1], + semver_db.parse("1.1.1-1.11.z.24.y.255").__composite_values__()[1], + semver_db.parse("1.1.1-alp.1.2.3.4.5.6").__composite_values__()[1]] + for i in xrange(len(versions) - 1): + self.assertLess(versions[i], versions[i + 1]) + + def test_too_large_version(self): + version1 = '1.1.65536' + version2 = '1.65536.1' + version3 = '65536.1.1' + self.assertRaises(exception.InvalidVersion, semver_db.parse, version1) + self.assertRaises(exception.InvalidVersion, semver_db.parse, version2) + self.assertRaises(exception.InvalidVersion, semver_db.parse, version3) + + def test_too_long_numeric_segments(self): + version = semver_db.parse('1.0.0-alpha.1234567') + self.assertRaises(exception.InvalidVersion, + version.__composite_values__) diff --git a/requirements.txt b/requirements.txt index ef441661f5..0378ca1243 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ Paste jsonschema>=2.0.0,<3.0.0 python-keystoneclient>=1.1.0 pyOpenSSL>=0.11 - +semantic_version>=2.3.1 # Required by openstack.common libraries six>=1.9.0