From c88781ecc36dd8edad5416993e722394f0ab2807 Mon Sep 17 00:00:00 2001 From: Alexander Tivelkov Date: Thu, 17 Jul 2014 16:47:51 +0400 Subject: [PATCH] SemVer utility to store object versions in DB Adds an utility which may convert Version objects (provided by semantic_version library) into a combination of long 64-bit integer value and two preformatted strings. If stored in the database these values unambiguously define object precedence according to semver notation. This may be used for correct ordering of database queries. Adds a class which is compatible with SQLAlchemy composite field, so one may use it directly in SQLAlchemy object models to define version fields and query the database. Needed for Artifacts model Depends-on: I74c00625634f246a96a1a9db4e6ff4335e649404 Implements-blueprint: semver-support Change-Id: I33212ea92cca50a143b9141f8147f7db27bb9e7c --- glance/common/exception.py | 4 + glance/common/semver_db.py | 144 ++++++++++++++++++++++++ glance/tests/unit/common/test_semver.py | 77 +++++++++++++ requirements.txt | 2 +- 4 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 glance/common/semver_db.py create mode 100644 glance/tests/unit/common/test_semver.py 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