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