Coverage for versio/version.py : 86%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# coding=utf-8
2"""
3A generic version class that supports comparison and version bumping (incrementing by part, for example you may
4bump the minor part of '1.2.3' yielding '1.3.0').
6Four version schemes are included::
8 * Simple3VersionScheme which supports 3 numerical part versions (A.B.C where A, B, and C are integers)
9 * Simple4VersionScheme which supports 4 numerical part versions (A.B.C.D where A, B, C, and D are integers)
10 * Pep440VersionScheme which supports PEP 440 (http://www.python.org/dev/peps/pep-0440/) versions
11 (N[.N]+[{a|b|c|rc}N][.postN][.devN])
12 * PerlVersionScheme which supports 2 numerical part versions where the second part is at least two digits
13 (A.BB where A and B are integers and B is zero padded on the left. For example: 1.02, 1.34, 1.567)
15If you don't specify which version scheme the version instance uses, then it will use the first scheme from the
16SupportedVersionSchemes list that successfully parses the version string
18By default, Pep440VersionScheme is the supported scheme. To change to a different list of schemes, use the
19**Version.set_supported_version_schemes(schemes)**. For example::
21 from versio.version_scheme import Simple3VersionScheme, PerlVersionScheme
22 Version.set_supported_version_schemes([Simple3VersionScheme, PerlVersionScheme])
24In addition, you may define your own version scheme by extending VersionScheme.
25"""
27__docformat__ = "restructuredtext en"
29import re
30from versio.comparable_mixin import ComparableMixin
31from versio.version_scheme import Pep440VersionScheme, VersionScheme
34class Version(ComparableMixin):
35 """
36 A version class that supports multiple versioning schemes, version comparisons,
37 and version bumping.
38 """
40 # Version uses the SupportedVersionSchemes list when trying to parse a string where
41 # the given scheme is None. The parsing attempts will be sequentially thru this list
42 # until a match is found.
43 supported_version_schemes = [
44 # Simple3VersionScheme,
45 # Simple4VersionScheme,
46 Pep440VersionScheme,
47 ]
49 def _cmpkey(self, other=None):
50 """
51 A key for comparisons required by ComparableMixin
53 Here we just use the list of version parts.
54 """
55 parts = self.parts[:]
56 if self.compare_order:
57 for index, value in enumerate(self.compare_order):
58 parts[index] = self.parts[value]
59 # if self.parts_reverse:
60 # parts.reverse()
61 key = []
62 for index, part in enumerate(parts):
63 if part is None:
64 if self.compare_fill is None:
65 key.append("~")
66 else:
67 key.append(self.compare_fill[index])
68 else:
69 sub_parts = part.split(".")
70 for sub_part in sub_parts:
71 if sub_part:
72 try:
73 key.append(int(sub_part))
74 except ValueError:
75 key.append(sub_part)
77 try:
78 if other is not None:
79 other_part = other.parts[index]
80 if other_part is not None:
81 extra_sequences = len(other_part.split(".")) - len(
82 sub_parts
83 )
84 if extra_sequences > 0:
85 try:
86 key += [
87 int(self.scheme.extend_value)
88 ] * extra_sequences
89 except ValueError:
90 key += [self.scheme.extend_value] * extra_sequences
91 except IndexError as ex:
92 print(str(ex))
94 return key
96 def _compare(self, other, method):
97 """
98 Compare an object with this object using the given comparison method.
100 :param other: object ot compare with
101 :type other: ComparableMixin
102 :param method: a comparison method
103 :type method: lambda
104 :return: asserted if comparison is true
105 :rtype: bool
106 :raises: NotImplemented
107 """
109 if not isinstance(other, Version):
110 try:
111 other = Version(str(other), scheme=self.scheme)
112 except AttributeError:
113 return NotImplemented
115 try:
116 x_cmpkey = self._cmpkey(other)[:]
117 y_cmpkey = other._cmpkey(self)[:]
118 # make same length
119 x_cmpkey += [self.scheme.clear_value] * (len(y_cmpkey) - len(x_cmpkey))
120 y_cmpkey += [self.scheme.clear_value] * (len(x_cmpkey) - len(y_cmpkey))
122 for index, x in enumerate(x_cmpkey):
123 y = y_cmpkey[index]
124 try:
125 if int(x) == int(y):
126 continue
127 except (TypeError, ValueError):
128 if str(x) == str(y):
129 continue
131 try:
132 if method(int(x), int(y)):
133 return True
134 except (TypeError, ValueError):
135 if method(str(x), str(y)):
136 return True
137 return False
139 x0 = x_cmpkey[0]
140 y0 = y_cmpkey[0]
142 try:
143 if method(int(x0), int(y0)):
144 return True
145 except (TypeError, ValueError):
146 if method(str(x0), str(y0)):
147 return True
148 return False
149 except (AttributeError, TypeError):
150 # _cmpkey not implemented, or return different type,
151 # so I can't compare with "other".
152 return NotImplemented
154 @classmethod
155 def set_supported_version_schemes(cls, schemes):
156 """
157 Set the list of version schemes used when parsing a string.
159 :param schemes: list of version schemes.
160 """
161 cls.supported_version_schemes = list(schemes)
163 def __init__(self, version_str=None, scheme=None):
164 """
165 Creates a version instance which is bound to a version scheme.
167 :param version_str: the initial version as a string
168 :type version_str: str or None
169 :param scheme: the version scheme to use to parse the version string or None to try all supported
170 version schemes.
171 :type scheme: VersionScheme or None
172 """
173 self.scheme, self.parts = self._parse(version_str, scheme)
175 if not self.scheme:
176 raise AttributeError(
177 'Can not find supported scheme for "{ver}"'.format(ver=version_str)
178 )
180 if not self.parts:
181 raise AttributeError('Can not parse "{ver}"'.format(ver=version_str))
183 self.compare_order = self.scheme.compare_order
184 self.compare_fill = self.scheme.compare_fill
186 def _parse(self, version_str, scheme):
187 """
188 Parse the given version string using the given version scheme. If the given version scheme
189 is None, then try all supported version schemes stopping with the first one able to successfully
190 parse the version string.
192 :param version_str: the version string to parse
193 :type version_str: str
194 :param scheme: the version scheme to use
195 :type scheme: VersionScheme or None
196 :return: the version scheme that can parse the version string, and the version parts as parsed.
197 :rtype: VersionScheme or None, list
198 """
199 if scheme is None:
200 for trial_scheme in self.supported_version_schemes:
201 parts = self._parse_with_scheme(version_str, trial_scheme)
202 if parts:
203 return trial_scheme, parts
204 else:
205 return scheme, self._parse_with_scheme(version_str, scheme)
206 return None
208 # noinspection PyMethodMayBeStatic
209 def _parse_with_scheme(self, version_str, scheme):
210 """
211 Parse the version string with the given version scheme.
213 :param version_str: the version string to parse
214 :type version_str: str or None
215 :param scheme: the version scheme to use
216 :type scheme: VersionScheme
217 :returns the parts of the version identified with the regular expression or None.
218 :rtype: list of str or None
219 """
220 if version_str is None:
221 return False
222 return scheme.parse(version_str)
224 def __str__(self):
225 """
226 Render to version to a string.
228 :return: the version as a string.
229 :rtype: str
230 """
231 if self.parts:
232 # for variable dotted scheme
233 if getattr(self.scheme, "join_str", None) is not None:
234 return self.scheme.join_str.join(self.parts)
236 # for other schemes
237 casts = self.scheme.format_types
238 casts += [str] * (
239 len(self.scheme.fields) - len(casts)
240 ) # right fill with str types
242 parts = [part or "" for part in self.parts]
243 parts += [""] * (
244 len(self.scheme.fields) - len(parts)
245 ) # right fill with blanks
247 def _type_cast(value, cast):
248 """cast the given value to the given cast or str if cast is None"""
249 if cast is None:
250 cast = str
251 result = None
252 try:
253 result = cast(value)
254 except ValueError:
255 pass
256 return result
258 args = list(map(_type_cast, parts, casts))
259 return self.scheme.format_str.format(*args)
260 return "Unknown version"
262 def bump(self, field_name=None, sub_index=-1, sequence=-1, promote=False):
263 """
264 Bump the given version field by 1. If no field name is given,
265 then bump the least significant field.
267 Optionally can bump by sequence index where 0 is the left most part of the version.
268 If a field_name is given, then the sequence value will be ignored.
270 :param field_name: the field name that matches one of the scheme's fields
271 :type field_name: object
272 :param sub_index: index in field
273 :type sub_index: int
274 :param sequence: the zero offset sequence index to bump.
275 :type sequence: int
276 :param promote: assert if end of field sequence causes field to be cleared
277 :type promote: bool
278 :return: True on success
279 :rtype: bool
280 """
281 if field_name is None:
282 if sequence >= 0:
283 # noinspection PyUnusedLocal
284 for idx in range(len(self.parts) - 1, sequence):
285 self.parts.append(self.scheme.clear_value)
286 self.parts[sequence] = str(int(self.parts[sequence]) + 1)
287 for idx in range(sequence + 1, len(self.parts)):
288 self.parts[idx] = self.scheme.clear_value
289 return True
290 if getattr(self.scheme, "fields", None) is None:
291 self.parts[-1] = str(int(self.parts[-1]) + 1)
292 return True
293 field_name = self.scheme.fields[-1]
294 field_name = field_name.lower()
296 index = 0
297 # noinspection PyBroadException
298 try:
299 bumped = False
300 index = self.scheme.fields.index(field_name)
301 for idx, part in enumerate(self.parts):
302 if idx == index:
303 bumped_part = self._bump_parse(field_name, part, sub_index)
304 if self.parts[idx] != bumped_part:
305 self.parts[idx] = bumped_part
306 bumped = True
308 if idx > index:
309 self.parts[idx] = self.scheme.clear_value
310 return bumped
311 except (IndexError, ValueError):
312 # not if fields, try subfields
313 if field_name in self.scheme.subfields:
314 return self.bump(*self.scheme.subfields[field_name])
315 if promote:
316 self.parts[index] = self.scheme.clear_value
317 return True
318 return False
320 def _increment(self, field_name, value):
321 """
322 Increment the value for the given field.
324 :param field_name: the field we are incrementing
325 :type field_name: str
326 :param value: the field's value
327 :type value: int or str
328 :return: the value after incrementing
329 :rtype: int or str
330 """
331 if isinstance(value, int):
332 value += 1
333 if isinstance(value, str):
334 if field_name in self.scheme.sequences:
335 seq_list = self.scheme.sequences[field_name]
336 if not value:
337 return seq_list[0]
338 if value not in seq_list:
339 raise AttributeError(
340 "Can not bump version, the current value (%s) not in sequence constraints"
341 % value
342 )
343 idx = seq_list.index(value) + 1
344 if idx < len(seq_list):
345 return seq_list[idx]
346 else:
347 raise IndexError("Can not increment past end of sequence")
348 else:
349 value = chr(ord(value) + 1)
350 return value
352 def _part_increment(self, field_name, sub_index, separator, sub_parts, clear_value):
353 """
354 Increment a version part, including handing parts to the right of the field being incremented.
356 :param field_name: the field we are incrementing
357 :type field_name: str
358 :param sub_index: the index of the sub part we are incrementing
359 :type sub_index: int
360 :param separator: the separator between sub parts
361 :type separator: str|None
362 :param sub_parts: the sub parts of a version part
363 :type sub_parts: list[int|str]
364 :param clear_value: the value to set parts to the right of this part to after incrementing.
365 :type clear_value: str|None
366 :return:
367 :rtype:
368 """
369 sub_parts[sub_index] = self._increment(field_name, sub_parts[sub_index])
370 if sub_index >= 0:
371 for sub_idx in range(sub_index + 1, len(sub_parts)):
372 sub_parts[sub_idx] = clear_value
373 return separator.join([str(n) for n in sub_parts])
375 def _bump_parse(self, field_name, part, sub_index):
376 """
377 Bump (increment) the given field of a version.
379 :param field_name: the field we are incrementing
380 :type field_name: str
381 :param part: the version part being incremented
382 :type part: str or None
383 :param sub_index:
384 :type sub_index:
385 :return: the version part after incrementing
386 :rtype: int or str or None
387 """
388 if part is None:
389 value = self.scheme.clear_value or "1"
390 return "{seq}{value}".format(
391 seq=self.scheme.sequences[field_name][0], value=value
392 )
394 # noinspection RegExpRedundantEscape
395 match = re.match(r"^\d[\.\d]*(?<=\d)$", part)
396 if match:
397 # dotted numeric (ex: '1.2.3')
398 return self._part_increment(
399 field_name,
400 sub_index,
401 ".",
402 [int(n) for n in part.split(".")],
403 self.scheme.clear_value or "0",
404 )
406 match = re.match(r"(\.?[a-zA-Z+]*)(\d+)", part)
407 if match:
408 # alpha + numeric (ex: 'a1', 'rc2', '.post3')
409 return self._part_increment(
410 field_name,
411 sub_index,
412 "",
413 [match.group(1) or "", int(match.group(2))],
414 self.scheme.clear_value or "1",
415 )
417 return part