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
This commit is contained in:
Alexander Tivelkov 2014-07-17 16:47:51 +04:00
parent 7217bd35c8
commit c88781ecc3
4 changed files with 226 additions and 1 deletions

View File

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

144
glance/common/semver_db.py Normal file
View File

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

View File

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

View File

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