27
27
)
28
28
29
29
# These types are required here because of circular imports
30
- Comparable = Union ["Version" , Dict [str , VersionPart ], Collection [VersionPart ], str ]
30
+ Comparable = Union ["Version" , Dict [str , VersionPart ], Collection [VersionPart ], String ]
31
31
Comparator = Callable [["Version" , Comparable ], bool ]
32
32
33
33
T = TypeVar ("T" , bound = "Version" )
@@ -65,7 +65,7 @@ class Version:
65
65
66
66
* a maximum length of 5 items that comprehend the major,
67
67
minor, patch, prerelease, or build parts.
68
- * a str or bytes string that contains a valid semver
68
+ * a str or bytes string at first position that contains a valid semver
69
69
version string.
70
70
:param major: version when you make incompatible API changes.
71
71
:param minor: version when you add functionality in
@@ -85,6 +85,21 @@ class Version:
85
85
Version(major=2, minor=3, patch=4, prerelease=None, build="build.2")
86
86
"""
87
87
88
+ #: The name of the version parts
89
+ VERSIONPARTS : Tuple [str , str , str , str , str ] = (
90
+ "major" , "minor" , "patch" , "prerelease" , "build"
91
+ )
92
+ #: The default values for each part (position match with ``VERSIONPARTS``):
93
+ VERSIONPARTDEFAULTS : VersionTuple = (0 , 0 , 0 , None , None )
94
+ #: The allowed types for each part (position match with ``VERSIONPARTS``):
95
+ ALLOWED_TYPES = (
96
+ (int , str , bytes ), # major
97
+ (int , str , bytes ), # minor
98
+ (int , str , bytes ), # patch
99
+ (int , str , bytes , type (None )), # prerelease
100
+ (int , str , bytes , type (None )), # build
101
+ )
102
+
88
103
__slots__ = ("_major" , "_minor" , "_patch" , "_prerelease" , "_build" )
89
104
90
105
#: The names of the different parts of a version
@@ -125,6 +140,45 @@ class Version:
125
140
re .VERBOSE ,
126
141
)
127
142
143
+ def _check_types (self , * args : Tuple ) -> List [bool ]:
144
+ """
145
+ Check if the given arguments conform to the types in ``ALLOWED_TYPES``.
146
+
147
+ :return: bool for each position
148
+ """
149
+ cls = self .__class__
150
+ return [
151
+ isinstance (item , expected_type )
152
+ for item , expected_type in zip (args , cls .ALLOWED_TYPES )
153
+ ]
154
+
155
+ def _raise_if_args_are_invalid (self , * args ):
156
+ """
157
+ Checks conditions for positional arguments. For example:
158
+
159
+ * No more than 5 arguments.
160
+ * If first argument is a string, contains a dot, and there
161
+ are more arguments.
162
+ * Arguments have invalid types.
163
+
164
+ :raises ValueError: if more arguments than 5 or if first argument
165
+ is a string, contains a dot, and there are more arguments.
166
+ :raises TypeError: if there are invalid types.
167
+ """
168
+ if args and len (args ) > 5 :
169
+ raise ValueError ("You cannot pass more than 5 arguments to Version" )
170
+ elif len (args ) > 1 and "." in str (args [0 ]):
171
+ raise ValueError (
172
+ "You cannot pass a string and additional positional arguments"
173
+ )
174
+ types_in_args = self ._check_types (* args )
175
+ if not all (types_in_args ):
176
+ pos = types_in_args .index (False )
177
+ raise TypeError (
178
+ "not expecting type in argument position "
179
+ f"{ pos } (type: { type (args [pos ])} )"
180
+ )
181
+
128
182
def __init__ (
129
183
self ,
130
184
* args : Tuple [
@@ -140,70 +194,75 @@ def __init__(
140
194
prerelease : Optional [Union [String , int ]] = None ,
141
195
build : Optional [Union [String , int ]] = None ,
142
196
):
143
- def _check_types (* args ):
144
- if args and len (args ) > 5 :
145
- raise ValueError ("You cannot pass more than 5 arguments to Version" )
146
- elif len (args ) > 1 and "." in str (args [0 ]):
147
- raise ValueError (
148
- "You cannot pass a string and additional positional arguments"
149
- )
150
- allowed_types_in_args = (
151
- (int , str , bytes ), # major
152
- (int , str , bytes ), # minor
153
- (int , str , bytes ), # patch
154
- (int , str , bytes , type (None )), # prerelease
155
- (int , str , bytes , type (None )), # build
156
- )
157
- return [
158
- isinstance (item , expected_type )
159
- for item , expected_type in zip (args , allowed_types_in_args )
160
- ]
197
+ #
198
+ # The algorithm to support different Version calls is this:
199
+ #
200
+ # 1. Check first, if there are invalid calls. For example
201
+ # more than 5 items in args or a unsupported combination
202
+ # of args and version part arguments (major, minor, etc.)
203
+ # If yes, raise an exception.
204
+ #
205
+ # 2. Create a dictargs dict:
206
+ # a. If the first argument is a version string which contains
207
+ # a dot it's likely it's a semver string. Try to convert
208
+ # them into a dict and save it to dictargs.
209
+ # b. If the first argument is not a version string, try to
210
+ # create the dictargs from the args argument.
211
+ #
212
+ # 3. Create a versiondict from the version part arguments.
213
+ # This contains only items if the argument is not None.
214
+ #
215
+ # 4. Merge the two dicts, versiondict overwrites dictargs.
216
+ # In other words, if the user specifies Version(1, major=2)
217
+ # the major=2 has precedence over the 1.
218
+ #
219
+ # 5. Set all version components from versiondict. If the key
220
+ # doesn't exist, set a default value.
161
221
162
222
cls = self .__class__
163
- verlist : List [Optional [StringOrInt ]] = [None , None , None , None , None ]
223
+ # (1) check combinations and types
224
+ self ._raise_if_args_are_invalid (* args )
164
225
165
- types_in_args = _check_types (* args )
166
- if not all (types_in_args ):
167
- pos = types_in_args .index (False )
168
- raise TypeError (
169
- "not expecting type in argument position "
170
- f"{ pos } (type: { type (args [pos ])} )"
171
- )
172
- elif args and "." in str (args [0 ]):
173
- # we have a version string as first argument
174
- v = cls ._parse (args [0 ]) # type: ignore
175
- for idx , key in enumerate (
176
- ("major" , "minor" , "patch" , "prerelease" , "build" )
177
- ):
178
- verlist [idx ] = v [key ]
226
+ # (2) First argument was a string
227
+ if args and args [0 ] and "." in cls ._enforce_str (args [0 ]): # type: ignore
228
+ dictargs = cls ._parse (cast (String , args [0 ]))
179
229
else :
180
- for index , item in enumerate (args ):
181
- verlist [index ] = args [index ] # type: ignore
230
+ dictargs = dict (zip (cls .VERSIONPARTS , args ))
182
231
183
- # Build a dictionary of the arguments except prerelease and build
184
- try :
185
- version_parts = {
186
- # Prefer major, minor, and patch arguments over args
187
- "major" : int (major or verlist [0 ] or 0 ),
188
- "minor" : int (minor or verlist [1 ] or 0 ),
189
- "patch" : int (patch or verlist [2 ] or 0 ),
190
- }
191
- except ValueError :
192
- raise ValueError (
193
- "Expected integer or integer string for major, minor, or patch"
232
+ # (3) Only include part in versiondict if value is not None
233
+ versiondict = {
234
+ part : value
235
+ for part , value in zip (
236
+ cls .VERSIONPARTS , (major , minor , patch , prerelease , build )
194
237
)
238
+ if value is not None
239
+ }
195
240
196
- for name , value in version_parts .items ():
197
- if value < 0 :
198
- raise ValueError (
199
- "{!r} is negative. A version can only be positive." .format (name )
200
- )
241
+ # (4) Order here is important: versiondict overwrites dictargs
242
+ versiondict = {** dictargs , ** versiondict } # type: ignore
201
243
202
- self ._major = version_parts ["major" ]
203
- self ._minor = version_parts ["minor" ]
204
- self ._patch = version_parts ["patch" ]
205
- self ._prerelease = cls ._enforce_str (prerelease or verlist [3 ])
206
- self ._build = cls ._enforce_str (build or verlist [4 ])
244
+ # (5) Set all version components:
245
+ self ._major = cls ._ensure_int (
246
+ cast (StringOrInt , versiondict .get ("major" , cls .VERSIONPARTDEFAULTS [0 ]))
247
+ )
248
+ self ._minor = cls ._ensure_int (
249
+ cast (StringOrInt , versiondict .get ("minor" , cls .VERSIONPARTDEFAULTS [1 ]))
250
+ )
251
+ self ._patch = cls ._ensure_int (
252
+ cast (StringOrInt , versiondict .get ("patch" , cls .VERSIONPARTDEFAULTS [2 ]))
253
+ )
254
+ self ._prerelease = cls ._enforce_str (
255
+ cast (
256
+ Optional [StringOrInt ],
257
+ versiondict .get ("prerelease" , cls .VERSIONPARTDEFAULTS [3 ]),
258
+ )
259
+ )
260
+ self ._build = cls ._enforce_str (
261
+ cast (
262
+ Optional [StringOrInt ],
263
+ versiondict .get ("build" , cls .VERSIONPARTDEFAULTS [4 ]),
264
+ )
265
+ )
207
266
208
267
@classmethod
209
268
def _nat_cmp (cls , a , b ): # TODO: type hints
@@ -228,6 +287,31 @@ def cmp_prerelease_tag(a, b):
228
287
else :
229
288
return _cmp (len (a ), len (b ))
230
289
290
+ @classmethod
291
+ def _ensure_int (cls , value : StringOrInt ) -> int :
292
+ """
293
+ Ensures integer value type regardless if argument type is str or bytes.
294
+ Otherwise raise ValueError.
295
+
296
+ :param value:
297
+ :raises ValueError: Two conditions:
298
+ * If value is not an integer or cannot be converted.
299
+ * If value is negative.
300
+ :return: the converted value as integer
301
+ """
302
+ try :
303
+ value = int (value )
304
+ except ValueError :
305
+ raise ValueError (
306
+ "Expected integer or integer string for major, minor, or patch"
307
+ )
308
+
309
+ if value < 0 :
310
+ raise ValueError (
311
+ f"Argument { value } is negative. A version can only be positive."
312
+ )
313
+ return value
314
+
231
315
@classmethod
232
316
def _enforce_str (cls , s : Optional [StringOrInt ]) -> Optional [str ]:
233
317
"""
@@ -486,8 +570,12 @@ def compare(self, other: Comparable) -> int:
486
570
0
487
571
"""
488
572
cls = type (self )
573
+
574
+ # See https://github.com/python/mypy/issues/4019
489
575
if isinstance (other , String .__args__ ): # type: ignore
490
- other = cls .parse (other )
576
+ if "." not in cast (str , cls ._ensure_str (other )):
577
+ raise ValueError ("Expected semver version string." )
578
+ other = cls (other )
491
579
elif isinstance (other , dict ):
492
580
other = cls (** other )
493
581
elif isinstance (other , (tuple , list )):
0 commit comments