blob: 1970322c4b6463131c8916e702f61afc8d436831 [file] [log] [blame]
Tarek Ziade1231a4e2011-05-19 13:07:25 +02001"""Implementation of the versioning scheme defined in PEP 386."""
2
3import re
4
5from packaging.errors import IrrationalVersionError, HugeMajorVersionNumError
6
7__all__ = ['NormalizedVersion', 'suggest_normalized_version',
8 'VersionPredicate', 'is_valid_version', 'is_valid_versions',
9 'is_valid_predicate']
10
11# A marker used in the second and third parts of the `parts` tuple, for
12# versions that don't have those segments, to sort properly. An example
13# of versions in sort order ('highest' last):
Éric Araujo40e0f352012-02-27 11:47:44 +010014# 1.0b1 ((1,0), ('b',1), ('z',))
15# 1.0.dev345 ((1,0), ('z',), ('dev', 345))
16# 1.0 ((1,0), ('z',), ('z',))
17# 1.0.post256.dev345 ((1,0), ('z',), ('z', 'post', 256, 'dev', 345))
18# 1.0.post345 ((1,0), ('z',), ('z', 'post', 345, 'z'))
Tarek Ziade1231a4e2011-05-19 13:07:25 +020019# ^ ^ ^
Éric Araujo40e0f352012-02-27 11:47:44 +010020# 'b' < 'z' ---------------------/ | |
Tarek Ziade1231a4e2011-05-19 13:07:25 +020021# | |
Éric Araujo40e0f352012-02-27 11:47:44 +010022# 'dev' < 'z' ----------------------------/ |
Tarek Ziade1231a4e2011-05-19 13:07:25 +020023# |
Éric Araujo40e0f352012-02-27 11:47:44 +010024# 'dev' < 'z' ----------------------------------------------/
25# 'f' for 'final' would be kind of nice, but due to bugs in the support of
26# 'rc' we must use 'z'
27_FINAL_MARKER = ('z',)
Tarek Ziade1231a4e2011-05-19 13:07:25 +020028
29_VERSION_RE = re.compile(r'''
30 ^
31 (?P<version>\d+\.\d+) # minimum 'N.N'
32 (?P<extraversion>(?:\.\d+)*) # any number of extra '.N' segments
33 (?:
34 (?P<prerel>[abc]|rc) # 'a'=alpha, 'b'=beta, 'c'=release candidate
35 # 'rc'= alias for release candidate
36 (?P<prerelversion>\d+(?:\.\d+)*)
37 )?
38 (?P<postdev>(\.post(?P<post>\d+))?(\.dev(?P<dev>\d+))?)?
39 $''', re.VERBOSE)
40
41
42class NormalizedVersion:
43 """A rational version.
44
45 Good:
46 1.2 # equivalent to "1.2.0"
47 1.2.0
48 1.2a1
49 1.2.3a2
50 1.2.3b1
51 1.2.3c1
52 1.2.3.4
53 TODO: fill this out
54
55 Bad:
56 1 # mininum two numbers
57 1.2a # release level must have a release serial
58 1.2.3b
59 """
60 def __init__(self, s, error_on_huge_major_num=True):
61 """Create a NormalizedVersion instance from a version string.
62
63 @param s {str} The version string.
64 @param error_on_huge_major_num {bool} Whether to consider an
65 apparent use of a year or full date as the major version number
66 an error. Default True. One of the observed patterns on PyPI before
67 the introduction of `NormalizedVersion` was version numbers like
68 this:
69 2009.01.03
70 20040603
71 2005.01
72 This guard is here to strongly encourage the package author to
73 use an alternate version, because a release deployed into PyPI
74 and, e.g. downstream Linux package managers, will forever remove
75 the possibility of using a version number like "1.0" (i.e.
76 where the major number is less than that huge major number).
77 """
78 self.is_final = True # by default, consider a version as final.
79 self._parse(s, error_on_huge_major_num)
80
81 @classmethod
82 def from_parts(cls, version, prerelease=_FINAL_MARKER,
83 devpost=_FINAL_MARKER):
84 return cls(cls.parts_to_str((version, prerelease, devpost)))
85
86 def _parse(self, s, error_on_huge_major_num=True):
87 """Parses a string version into parts."""
88 match = _VERSION_RE.search(s)
89 if not match:
90 raise IrrationalVersionError(s)
91
92 groups = match.groupdict()
93 parts = []
94
95 # main version
96 block = self._parse_numdots(groups['version'], s, False, 2)
97 extraversion = groups.get('extraversion')
98 if extraversion not in ('', None):
99 block += self._parse_numdots(extraversion[1:], s)
100 parts.append(tuple(block))
101
102 # prerelease
103 prerel = groups.get('prerel')
104 if prerel is not None:
105 block = [prerel]
106 block += self._parse_numdots(groups.get('prerelversion'), s,
107 pad_zeros_length=1)
108 parts.append(tuple(block))
109 self.is_final = False
110 else:
111 parts.append(_FINAL_MARKER)
112
113 # postdev
114 if groups.get('postdev'):
115 post = groups.get('post')
116 dev = groups.get('dev')
117 postdev = []
118 if post is not None:
119 postdev.extend((_FINAL_MARKER[0], 'post', int(post)))
120 if dev is None:
121 postdev.append(_FINAL_MARKER[0])
122 if dev is not None:
123 postdev.extend(('dev', int(dev)))
124 self.is_final = False
125 parts.append(tuple(postdev))
126 else:
127 parts.append(_FINAL_MARKER)
128 self.parts = tuple(parts)
129 if error_on_huge_major_num and self.parts[0][0] > 1980:
130 raise HugeMajorVersionNumError("huge major version number, %r, "
131 "which might cause future problems: %r" % (self.parts[0][0], s))
132
133 def _parse_numdots(self, s, full_ver_str, drop_trailing_zeros=True,
134 pad_zeros_length=0):
135 """Parse 'N.N.N' sequences, return a list of ints.
136
137 @param s {str} 'N.N.N...' sequence to be parsed
138 @param full_ver_str {str} The full version string from which this
139 comes. Used for error strings.
140 @param drop_trailing_zeros {bool} Whether to drop trailing zeros
141 from the returned list. Default True.
142 @param pad_zeros_length {int} The length to which to pad the
143 returned list with zeros, if necessary. Default 0.
144 """
145 nums = []
146 for n in s.split("."):
147 if len(n) > 1 and n[0] == '0':
148 raise IrrationalVersionError("cannot have leading zero in "
149 "version number segment: '%s' in %r" % (n, full_ver_str))
150 nums.append(int(n))
151 if drop_trailing_zeros:
152 while nums and nums[-1] == 0:
153 nums.pop()
154 while len(nums) < pad_zeros_length:
155 nums.append(0)
156 return nums
157
158 def __str__(self):
159 return self.parts_to_str(self.parts)
160
161 @classmethod
162 def parts_to_str(cls, parts):
163 """Transforms a version expressed in tuple into its string
164 representation."""
165 # XXX This doesn't check for invalid tuples
166 main, prerel, postdev = parts
167 s = '.'.join(str(v) for v in main)
168 if prerel is not _FINAL_MARKER:
169 s += prerel[0]
170 s += '.'.join(str(v) for v in prerel[1:])
Éric Araujo40e0f352012-02-27 11:47:44 +0100171 # XXX clean up: postdev is always true; code is obscure
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200172 if postdev and postdev is not _FINAL_MARKER:
Éric Araujo40e0f352012-02-27 11:47:44 +0100173 if postdev[0] == _FINAL_MARKER[0]:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200174 postdev = postdev[1:]
175 i = 0
176 while i < len(postdev):
177 if i % 2 == 0:
178 s += '.'
179 s += str(postdev[i])
180 i += 1
181 return s
182
183 def __repr__(self):
184 return "%s('%s')" % (self.__class__.__name__, self)
185
186 def _cannot_compare(self, other):
187 raise TypeError("cannot compare %s and %s"
188 % (type(self).__name__, type(other).__name__))
189
190 def __eq__(self, other):
191 if not isinstance(other, NormalizedVersion):
192 self._cannot_compare(other)
193 return self.parts == other.parts
194
195 def __lt__(self, other):
196 if not isinstance(other, NormalizedVersion):
197 self._cannot_compare(other)
198 return self.parts < other.parts
199
200 def __ne__(self, other):
201 return not self.__eq__(other)
202
203 def __gt__(self, other):
204 return not (self.__lt__(other) or self.__eq__(other))
205
206 def __le__(self, other):
207 return self.__eq__(other) or self.__lt__(other)
208
209 def __ge__(self, other):
210 return self.__eq__(other) or self.__gt__(other)
211
212 # See http://docs.python.org/reference/datamodel#object.__hash__
213 def __hash__(self):
214 return hash(self.parts)
215
216
217def suggest_normalized_version(s):
218 """Suggest a normalized version close to the given version string.
219
220 If you have a version string that isn't rational (i.e. NormalizedVersion
221 doesn't like it) then you might be able to get an equivalent (or close)
222 rational version from this function.
223
224 This does a number of simple normalizations to the given string, based
225 on observation of versions currently in use on PyPI. Given a dump of
226 those version during PyCon 2009, 4287 of them:
227 - 2312 (53.93%) match NormalizedVersion without change
228 with the automatic suggestion
229 - 3474 (81.04%) match when using this suggestion method
230
231 @param s {str} An irrational version string.
232 @returns A rational version string, or None, if couldn't determine one.
233 """
234 try:
235 NormalizedVersion(s)
236 return s # already rational
237 except IrrationalVersionError:
238 pass
239
240 rs = s.lower()
241
242 # part of this could use maketrans
243 for orig, repl in (('-alpha', 'a'), ('-beta', 'b'), ('alpha', 'a'),
244 ('beta', 'b'), ('rc', 'c'), ('-final', ''),
245 ('-pre', 'c'),
246 ('-release', ''), ('.release', ''), ('-stable', ''),
247 ('+', '.'), ('_', '.'), (' ', ''), ('.final', ''),
248 ('final', '')):
249 rs = rs.replace(orig, repl)
250
251 # if something ends with dev or pre, we add a 0
252 rs = re.sub(r"pre$", r"pre0", rs)
253 rs = re.sub(r"dev$", r"dev0", rs)
254
255 # if we have something like "b-2" or "a.2" at the end of the
256 # version, that is pobably beta, alpha, etc
257 # let's remove the dash or dot
Éric Araujoc4637712011-10-05 01:46:37 +0200258 rs = re.sub(r"([abc]|rc)[\-\.](\d+)$", r"\1\2", rs)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200259
260 # 1.0-dev-r371 -> 1.0.dev371
261 # 0.1-dev-r79 -> 0.1.dev79
262 rs = re.sub(r"[\-\.](dev)[\-\.]?r?(\d+)$", r".\1\2", rs)
263
264 # Clean: 2.0.a.3, 2.0.b1, 0.9.0~c1
265 rs = re.sub(r"[.~]?([abc])\.?", r"\1", rs)
266
267 # Clean: v0.3, v1.0
268 if rs.startswith('v'):
269 rs = rs[1:]
270
271 # Clean leading '0's on numbers.
272 #TODO: unintended side-effect on, e.g., "2003.05.09"
273 # PyPI stats: 77 (~2%) better
274 rs = re.sub(r"\b0+(\d+)(?!\d)", r"\1", rs)
275
276 # Clean a/b/c with no version. E.g. "1.0a" -> "1.0a0". Setuptools infers
277 # zero.
278 # PyPI stats: 245 (7.56%) better
279 rs = re.sub(r"(\d+[abc])$", r"\g<1>0", rs)
280
281 # the 'dev-rNNN' tag is a dev tag
282 rs = re.sub(r"\.?(dev-r|dev\.r)\.?(\d+)$", r".dev\2", rs)
283
284 # clean the - when used as a pre delimiter
285 rs = re.sub(r"-(a|b|c)(\d+)$", r"\1\2", rs)
286
287 # a terminal "dev" or "devel" can be changed into ".dev0"
288 rs = re.sub(r"[\.\-](dev|devel)$", r".dev0", rs)
289
290 # a terminal "dev" can be changed into ".dev0"
291 rs = re.sub(r"(?![\.\-])dev$", r".dev0", rs)
292
293 # a terminal "final" or "stable" can be removed
294 rs = re.sub(r"(final|stable)$", "", rs)
295
296 # The 'r' and the '-' tags are post release tags
297 # 0.4a1.r10 -> 0.4a1.post10
Éric Araujo2f8c3f72012-02-06 16:12:21 +0100298 # 0.9.33-17222 -> 0.9.33.post17222
299 # 0.9.33-r17222 -> 0.9.33.post17222
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200300 rs = re.sub(r"\.?(r|-|-r)\.?(\d+)$", r".post\2", rs)
301
302 # Clean 'r' instead of 'dev' usage:
Éric Araujo2f8c3f72012-02-06 16:12:21 +0100303 # 0.9.33+r17222 -> 0.9.33.dev17222
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200304 # 1.0dev123 -> 1.0.dev123
305 # 1.0.git123 -> 1.0.dev123
306 # 1.0.bzr123 -> 1.0.dev123
307 # 0.1a0dev.123 -> 0.1a0.dev123
308 # PyPI stats: ~150 (~4%) better
309 rs = re.sub(r"\.?(dev|git|bzr)\.?(\d+)$", r".dev\2", rs)
310
311 # Clean '.pre' (normalized from '-pre' above) instead of 'c' usage:
312 # 0.2.pre1 -> 0.2c1
313 # 0.2-c1 -> 0.2c1
314 # 1.0preview123 -> 1.0c123
315 # PyPI stats: ~21 (0.62%) better
316 rs = re.sub(r"\.?(pre|preview|-c)(\d+)$", r"c\g<2>", rs)
317
318 # Tcl/Tk uses "px" for their post release markers
319 rs = re.sub(r"p(\d+)$", r".post\1", rs)
320
321 try:
322 NormalizedVersion(rs)
323 return rs # already rational
324 except IrrationalVersionError:
325 pass
326 return None
327
328
329# A predicate is: "ProjectName (VERSION1, VERSION2, ..)
330_PREDICATE = re.compile(r"(?i)^\s*(\w[\s\w-]*(?:\.\w*)*)(.*)")
331_VERSIONS = re.compile(r"^\s*\((?P<versions>.*)\)\s*$|^\s*"
332 "(?P<versions2>.*)\s*$")
333_PLAIN_VERSIONS = re.compile(r"^\s*(.*)\s*$")
334_SPLIT_CMP = re.compile(r"^\s*(<=|>=|<|>|!=|==)\s*([^\s,]+)\s*$")
335
336
337def _split_predicate(predicate):
338 match = _SPLIT_CMP.match(predicate)
339 if match is None:
340 # probably no op, we'll use "=="
341 comp, version = '==', predicate
342 else:
343 comp, version = match.groups()
344 return comp, NormalizedVersion(version)
345
346
347class VersionPredicate:
348 """Defines a predicate: ProjectName (>ver1,ver2, ..)"""
349
350 _operators = {"<": lambda x, y: x < y,
351 ">": lambda x, y: x > y,
352 "<=": lambda x, y: str(x).startswith(str(y)) or x < y,
353 ">=": lambda x, y: str(x).startswith(str(y)) or x > y,
354 "==": lambda x, y: str(x).startswith(str(y)),
355 "!=": lambda x, y: not str(x).startswith(str(y)),
356 }
357
358 def __init__(self, predicate):
359 self._string = predicate
360 predicate = predicate.strip()
361 match = _PREDICATE.match(predicate)
362 if match is None:
363 raise ValueError('Bad predicate "%s"' % predicate)
364
365 name, predicates = match.groups()
366 self.name = name.strip()
367 self.predicates = []
368 if predicates is None:
369 return
370
371 predicates = _VERSIONS.match(predicates.strip())
372 if predicates is None:
373 return
374
375 predicates = predicates.groupdict()
376 if predicates['versions'] is not None:
377 versions = predicates['versions']
378 else:
379 versions = predicates.get('versions2')
380
381 if versions is not None:
382 for version in versions.split(','):
383 if version.strip() == '':
384 continue
385 self.predicates.append(_split_predicate(version))
386
387 def match(self, version):
388 """Check if the provided version matches the predicates."""
389 if isinstance(version, str):
390 version = NormalizedVersion(version)
391 for operator, predicate in self.predicates:
392 if not self._operators[operator](version, predicate):
393 return False
394 return True
395
396 def __repr__(self):
397 return self._string
398
399
400class _Versions(VersionPredicate):
401 def __init__(self, predicate):
402 predicate = predicate.strip()
403 match = _PLAIN_VERSIONS.match(predicate)
404 self.name = None
405 predicates = match.groups()[0]
406 self.predicates = [_split_predicate(pred.strip())
407 for pred in predicates.split(',')]
408
409
410class _Version(VersionPredicate):
411 def __init__(self, predicate):
412 predicate = predicate.strip()
413 match = _PLAIN_VERSIONS.match(predicate)
414 self.name = None
415 self.predicates = _split_predicate(match.groups()[0])
416
417
418def is_valid_predicate(predicate):
419 try:
420 VersionPredicate(predicate)
421 except (ValueError, IrrationalVersionError):
422 return False
423 else:
424 return True
425
426
427def is_valid_versions(predicate):
428 try:
429 _Versions(predicate)
430 except (ValueError, IrrationalVersionError):
431 return False
432 else:
433 return True
434
435
436def is_valid_version(predicate):
437 try:
438 _Version(predicate)
439 except (ValueError, IrrationalVersionError):
440 return False
441 else:
442 return True
443
444
445def get_version_predicate(requirements):
446 """Return a VersionPredicate object, from a string or an already
447 existing object.
448 """
449 if isinstance(requirements, str):
450 requirements = VersionPredicate(requirements)
451 return requirements