-
Notifications
You must be signed in to change notification settings - Fork 7
/
version.py
422 lines (354 loc) · 12.9 KB
/
version.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# SPDX-License-Identifier: MIT
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
# See https://github.com/nexB/debian-inspector for support or download.
# See https://aboutcode.org for more information about nexB OSS projects.
# Copyright (c) Peter Odding
# Author: Peter Odding <[email protected]>
# URL: https://github.com/xolox/python-deb-pkg-tools
#
import logging
import operator as operator_module
import re
from functools import cmp_to_key
from itertools import zip_longest
from attr import asdict
from attr import attrs
from attr import attrib
logger = logging.getLogger(__name__)
"""
Parse, compare and sort Debian package versions.
This module is an implementation of the version comparison and sorting algorithm
described at
https://www.debian.org/doc/debian-policy/ch-controlfields.html#s-f-Version
This has been substantially modified and enhanced from the original python-dpkg
Dpkg class by Nathan J. Meh and team from The Climate Corporation and
code from python-deb-pkg-tools by Peter Odding to extract only the subset that
does the version parsing, comparison and version constraints evaluatiob.
So much so that little of this code may still looks like the original.
Some examples:
#### Compare two arbitrary version strings
>>> from debian_inspector import debver
>>> debver.compare_versions('0:1.0-test1', '0:1.0-test2')
-1
>>> debver.compare_versions('1.0', '0.6')
1
>>> debver.compare_versions('2:1.0', '1:1.0')
-1
#### Use Version as a key function to sort a list of version strings
>>> from debian_inspector.debver import Version
>>> sorted(['0:1.0-test1', '1:0.0-test0', '0:1.0-test2'] , key=Version.from_string)
['0:1.0-test1', '0:1.0-test2', '1:0.0-test0']
"""
@attrs(
eq=False,
order=False,
frozen=True,
hash=False,
slots=True,
str=False
)
class Version(object):
"""
Rich comparison of Debian package versions as first-class Python objects.
The :class:`Version` class is a subclass of the built in :class:`str` type
that implements rich comparison according to the version sorting order
defined in the Debian Policy Manual. Use it to sort Debian package versions
from oldest to newest in ascending version order like this:
>>> from debian_inspector.version import Version
>>> unsorted = ['0.1', '0.5', '1.0', '2.0', '3.0', '1:0.4', '2:0.3']
>>> print([str(v) for v in sorted(Version.from_string(s) for s in unsorted)])
['0.1', '0.5', '1.0', '2.0', '3.0', '1:0.4', '2:0.3']
This example uses 'epoch' numbers (the numbers before the colons) to
demonstrate that this version sorting order is different from regular
sorting and 'natural order sorting'.
"""
epoch = attrib(default=0)
upstream = attrib(default=None)
revision = attrib(default='0')
def __str__(self, *args, **kwargs):
if self.epoch:
version = f'{self.epoch}:{self.upstream}'
else:
version = f'{self.upstream}'
if self.revision not in (None, '0'):
version += f'-{self.revision}'
return version
def __repr__(self, *args, **kwargs):
return str(self)
def __hash__(self):
return hash(self.tuple())
def __eq__(self, other):
return type(self) is type(other) and self.tuple() == other.tuple()
def __ne__(self, other):
return not self.__eq__(other)
def __lt__(self, other):
if type(self) is type(other):
return eval_constraint(self, '<<', other)
return NotImplemented
def __le__(self, other):
if type(self) is type(other):
return eval_constraint(self, '<=', other)
return NotImplemented
def __gt__(self, other):
if type(self) is type(other):
return eval_constraint(self, '>>', other)
return NotImplemented
def __ge__(self, other):
if type(self) is type(other):
return eval_constraint(self, '>=', other)
return NotImplemented
@classmethod
def from_string(cls, version):
if not version and not isinstance(version , str):
raise ValueError('Invalid version string: "{}"'.format(version))
version = version.strip()
if not version:
raise ValueError('Invalid version string: "{}"'.format(version))
if not _is_valid_version(version):
raise ValueError('Invalid version string: "{}"'.format(version))
if ":" in version:
epoch, _, version = version.partition(':')
epoch = int(epoch)
else:
epoch = 0
if "-" in version:
upstream, _, revision = version.rpartition('-')
else:
upstream = version
revision = '0'
return cls(epoch=epoch, upstream=upstream, revision=revision)
def compare(self, other_version):
return compare_versions(self, other_version)
def to_dict(self):
return asdict(self)
def tuple(self):
return self.epoch, self.upstream, self.revision
_is_valid_version = re.compile(
r'^'
# epoch must start with a digit
r'(\d+:)?'
# upstream must start with a digit
r'\d'
r'('
# upstream can contain only alphanumerics and the characters . + -
# ~ (full stop, plus, hyphen, tilde)
# we are adding the extra check that it must end with alphanum
r'[A-Za-z0-9\.\+\-\~]*[A-Za-z0-9]'
r'|'
# If there is no debian_revision then hyphens are not allowed.
# we are adding the extra check that it must end with alphanum
r'[A-Za-z0-9\.\+\~]*[A-Za-z0-9]-[A-Za-z0-9\+\.\~]*[A-Za-z0-9\~]'
r')?'
r'$').match
def eval_constraint(version1, operator, version2):
"""
Evaluate a versions constraint where two Debian package versions are
compared with an operator such as < or >. Return True if the constraint is
satisfied and False otherwise.
"""
version1 = coerce_version(version1)
version2 = coerce_version(version2)
result = compare_versions(version1, version2)
# See https://www.debian.org/doc/debian-policy/ch-relationships.html#syntax-of-relationship-fields
operators = {
'<=': operator_module.le,
# legacy for compat
'<': operator_module.le,
'>=': operator_module.ge,
# legacy for compat
'>': operator_module.ge,
'<<': operator_module.lt,
'>>': operator_module.gt,
'=': operator_module.eq,
}
try:
operator = operators[operator]
except KeyError:
msg = f'Unsupported Debian version constraint comparison operator: {version1} {operator} {version2}'
raise ValueError(msg)
return operator(result, 0)
def compare_versions_key(x):
"""
Return a key version function suitable for use in sorted().
"""
return cmp_to_key(compare_versions)(x)
def compare_strings_key(x):
"""
Return a key string function suitable for use in sorted().
"""
return cmp_to_key(compare_strings)(x)
def compare_strings(version1, version2):
"""
Compare two version strings (upstream or revision) using Debain semantics
and return one of the following integer numbers:
- -1 means version1 sorts before version2
- 0 means version1 and version2 are equal
- 1 means version1 sorts after version2
"""
logger.debug("Comparing Debian version number substrings %r and %r ..", version1, version2)
mapping = characters_order
v1 = list(version1)
v2 = list(version2)
while v1 or v2:
# Quoting from the 'deb-version' manual page: First the initial part of each
# string consisting entirely of non-digit characters is determined. These two
# parts (one of which may be empty) are compared lexically. If a difference is
# found it is returned. The lexical comparison is a comparison of ASCII values
# modified so that all the letters sort earlier than all the non-letters and so
# that a tilde sorts before anything, even the end of a part. For example, the
# following parts are in sorted order: '~~', '~~a', '~', the empty part, 'a'.
p1 = get_non_digit_prefix(v1)
p2 = get_non_digit_prefix(v2)
if p1 != p2:
logger.debug("Comparing non-digit prefixes %r and %r ..", p1, p2)
for c1, c2 in zip_longest(p1, p2, fillvalue=""):
logger.debug("Performing lexical comparison between characters %r and %r ..", c1, c2)
o1 = mapping.get(c1)
o2 = mapping.get(c2)
if o1 < o2:
logger.debug("Determined that %r sorts before %r (based on lexical comparison).", version1, version2)
return -1
elif o1 > o2:
logger.debug("Determined that %r sorts after %r (based on lexical comparison).", version1, version2)
return 1
elif p1:
logger.debug("Skipping matching non-digit prefix %r ..", p1)
# Quoting from the 'deb-version' manual page: Then the initial part of the
# remainder of each string which consists entirely of digit characters is
# determined. The numerical values of these two parts are compared, and any
# difference found is returned as the result of the comparison. For these purposes
# an empty string (which can only occur at the end of one or both version strings
# being compared) counts as zero.
d1 = get_digit_prefix(v1)
d2 = get_digit_prefix(v2)
logger.debug("Comparing numeric prefixes %i and %i ..", d1, d2)
if d1 < d2:
logger.debug("Determined that %r sorts before %r (based on numeric comparison).", version1, version2)
return -1
elif d1 > d2:
logger.debug("Determined that %r sorts after %r (based on numeric comparison).", version1, version2)
return 1
else:
logger.debug("Determined that numeric prefixes match.")
logger.debug("Determined that version numbers are equal.")
return 0
def compare_versions(version1, version2):
"""
Compare two Version objects or strings and return one of the following
integer numbers:
- -1 means version1 sorts before version2
- 0 means version1 and version2 are equal
- 1 means version1 sorts after version2
"""
version1 = coerce_version(version1)
version2 = coerce_version(version2)
return compare_version_objects(version1, version2)
def coerce_version(value):
"""
Return a Version object from value.
:param value: The value to coerce (a string or :class:`Version` object).
:returns: A :class:`Version` object.
"""
if not isinstance(value, Version):
value = Version.from_string(value)
return value
def compare_version_objects(version1, version2):
"""
Compare two Version objects and return one of the following
integer numbers:
- -1 means version1 sorts before version2
- 0 means version1 and version2 are equal
- 1 means version1 sorts after version2
"""
if version1.epoch < version2.epoch:
return -1
if version1.epoch > version2.epoch:
return 1
result = compare_strings(version1.upstream, version2.upstream)
if result != 0:
return result
if version1.revision or version2.revision:
return compare_strings(version1.revision, version2.revision)
return 0
def get_digit_prefix(characters):
"""
Return the digit prefix from a list of characters.
"""
value = 0
while characters and characters[0].isdigit():
value = value * 10 + int(characters.pop(0))
return value
def get_non_digit_prefix(characters):
"""
Return the non-digit prefix from a list of characters.
"""
prefix = []
while characters and not characters[0].isdigit():
prefix.append(characters.pop(0))
return prefix
# a mapping of characters to integers representing the Debian sort order.
characters_order = {
# The tilde sorts before everything.
'~': 0,
# The empty string sort before everything except a tilde.
'': 1,
# Letters sort before everything but a tilde or empty string, in their regular lexical sort order.
'A': 2,
'B': 3,
'C': 4,
'D': 5,
'E': 6,
'F': 7,
'G': 8,
'H': 9,
'I': 10,
'J': 11,
'K': 12,
'L': 13,
'M': 14,
'N': 15,
'O': 16,
'P': 17,
'Q': 18,
'R': 19,
'S': 20,
'T': 21,
'U': 22,
'V': 23,
'W': 24,
'X': 25,
'Y': 26,
'Z': 27,
'a': 28,
'b': 29,
'c': 30,
'd': 31,
'e': 32,
'f': 33,
'g': 34,
'h': 35,
'i': 36,
'j': 37,
'k': 38,
'l': 39,
'm': 40,
'n': 41,
'o': 42,
'p': 43,
'q': 44,
'r': 45,
's': 46,
't': 47,
'u': 48,
'v': 49,
'w': 50,
'x': 51,
'y': 52,
'z': 53,
# Punctuation characters follow in their regular lexical sort order.
'+': 54,
'-': 55,
'.': 56,
}