2727)
2828
2929# 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 ]
3131Comparator = Callable [["Version" , Comparable ], bool ]
3232
3333
@@ -63,7 +63,7 @@ class Version:
6363
6464 * a maximum length of 5 items that comprehend the major,
6565 minor, patch, prerelease, or build parts.
66- * a str or bytes string that contains a valid semver
66+ * a str or bytes string at first position that contains a valid semver
6767 version string.
6868 :param major: version when you make incompatible API changes.
6969 :param minor: version when you add functionality in
@@ -83,6 +83,19 @@ class Version:
8383 Version(major=2, minor=3, patch=4, prerelease=None, build="build.2")
8484 """
8585
86+ #: The name of the version parts
87+ VERSIONPARTS : Tuple [str ] = ("major" , "minor" , "patch" , "prerelease" , "build" )
88+ #: The default values for each part (position match with ``VERSIONPARTS``):
89+ VERSIONPARTDEFAULTS : VersionTuple = (0 , 0 , 0 , None , None )
90+ #: The allowed types for each part (position match with ``VERSIONPARTS``):
91+ ALLOWED_TYPES = (
92+ (int , str , bytes ), # major
93+ (int , str , bytes ), # minor
94+ (int , str , bytes ), # patch
95+ (int , str , bytes , type (None )), # prerelease
96+ (int , str , bytes , type (None )), # build
97+ )
98+
8699 __slots__ = ("_major" , "_minor" , "_patch" , "_prerelease" , "_build" )
87100 #: Regex for number in a prerelease
88101 _LAST_NUMBER = re .compile (r"(?:[^\d]*(\d+)[^\d]*)+" )
@@ -108,6 +121,45 @@ class Version:
108121 re .VERBOSE ,
109122 )
110123
124+ def _check_types (self , * args : Tuple ) -> List [bool ]:
125+ """
126+ Check if the given arguments conform to the types in ``ALLOWED_TYPES``.
127+
128+ :return: bool for each position
129+ """
130+ cls = self .__class__
131+ return [
132+ isinstance (item , expected_type )
133+ for item , expected_type in zip (args , cls .ALLOWED_TYPES )
134+ ]
135+
136+ def _raise_if_args_are_invalid (self , * args ):
137+ """
138+ Checks conditions for positional arguments. For example:
139+
140+ * No more than 5 arguments.
141+ * If first argument is a string, contains a dot, and there
142+ are more arguments.
143+ * Arguments have invalid types.
144+
145+ :raises ValueError: if more arguments than 5 or if first argument
146+ is a string, contains a dot, and there are more arguments.
147+ :raises TypeError: if there are invalid types.
148+ """
149+ if args and len (args ) > 5 :
150+ raise ValueError ("You cannot pass more than 5 arguments to Version" )
151+ elif len (args ) > 1 and "." in str (args [0 ]):
152+ raise ValueError (
153+ "You cannot pass a string and additional positional arguments"
154+ )
155+ types_in_args = self ._check_types (* args )
156+ if not all (types_in_args ):
157+ pos = types_in_args .index (False )
158+ raise TypeError (
159+ "not expecting type in argument position "
160+ f"{ pos } (type: { type (args [pos ])} )"
161+ )
162+
111163 def __init__ (
112164 self ,
113165 * args : Tuple [
@@ -117,76 +169,82 @@ def __init__(
117169 Optional [StringOrInt ], # prerelease
118170 Optional [StringOrInt ], # build
119171 ],
120- major : SupportsInt = 0 ,
121- minor : SupportsInt = 0 ,
122- patch : SupportsInt = 0 ,
172+ # *,
173+ major : SupportsInt = None ,
174+ minor : SupportsInt = None ,
175+ patch : SupportsInt = None ,
123176 prerelease : StringOrInt = None ,
124177 build : StringOrInt = None ,
125178 ):
126- def _check_types (* args ):
127- if args and len (args ) > 5 :
128- raise ValueError ("You cannot pass more than 5 arguments to Version" )
129- elif len (args ) > 1 and "." in str (args [0 ]):
130- raise ValueError (
131- "You cannot pass a string and additional positional arguments"
132- )
133- allowed_types_in_args = (
134- (int , str , bytes ), # major
135- (int , str , bytes ), # minor
136- (int , str , bytes ), # patch
137- (int , str , bytes , type (None )), # prerelease
138- (int , str , bytes , type (None )), # build
139- )
140- return [
141- isinstance (item , expected_type )
142- for item , expected_type in zip (args , allowed_types_in_args )
143- ]
179+ #
180+ # The algorithm to support different Version calls is this:
181+ #
182+ # 1. Check first, if there are invalid calls. For example
183+ # more than 5 items in args or a unsupported combination
184+ # of args and version part arguments (major, minor, etc.)
185+ # If yes, raise an exception.
186+ #
187+ # 2. Create a dictargs dict:
188+ # a. If the first argument is a version string which contains
189+ # a dot it's likely it's a semver string. Try to convert
190+ # them into a dict and save it to dictargs.
191+ # b. If the first argument is not a version string, try to
192+ # create the dictargs from the args argument.
193+ #
194+ # 3. Create a versiondict from the version part arguments.
195+ # This contains only items if the argument is not None.
196+ #
197+ # 4. Merge the two dicts, versiondict overwrites dictargs.
198+ # In other words, if the user specifies Version(1, major=2)
199+ # the major=2 has precedence over the 1.
200+ #
201+ # 5. Set all version components from versiondict. If the key
202+ # doesn't exist, set a default value.
144203
145204 cls = self .__class__
146- verlist : List [Optional [StringOrInt ]] = [None , None , None , None , None ]
205+ # (1) check combinations and types
206+ self ._raise_if_args_are_invalid (* args )
147207
148- types_in_args = _check_types (* args )
149- if not all (types_in_args ):
150- pos = types_in_args .index (False )
151- raise TypeError (
152- "not expecting type in argument position "
153- f"{ pos } (type: { type (args [pos ])} )"
154- )
155- elif args and "." in str (args [0 ]):
156- # we have a version string as first argument
157- v = cls ._parse (args [0 ]) # type: ignore
158- for idx , key in enumerate (
159- ("major" , "minor" , "patch" , "prerelease" , "build" )
160- ):
161- verlist [idx ] = v [key ]
208+ # (2) First argument was a string
209+ if args and args [0 ] and "." in cls ._enforce_str (args [0 ]): # type: ignore
210+ dictargs = cls ._parse (cast (String , args [0 ]))
162211 else :
163- for index , item in enumerate (args ):
164- verlist [index ] = args [index ] # type: ignore
212+ dictargs = dict (zip (cls .VERSIONPARTS , args ))
165213
166- # Build a dictionary of the arguments except prerelease and build
167- try :
168- version_parts = {
169- # Prefer major, minor, and patch arguments over args
170- "major" : int (major or verlist [0 ] or 0 ),
171- "minor" : int (minor or verlist [1 ] or 0 ),
172- "patch" : int (patch or verlist [2 ] or 0 ),
173- }
174- except ValueError :
175- raise ValueError (
176- "Expected integer or integer string for major, minor, or patch"
214+ # (3) Only include part in versiondict if value is not None
215+ versiondict = {
216+ part : value
217+ for part , value in zip (
218+ cls .VERSIONPARTS , (major , minor , patch , prerelease , build )
177219 )
220+ if value is not None
221+ }
178222
179- for name , value in version_parts .items ():
180- if value < 0 :
181- raise ValueError (
182- "{!r} is negative. A version can only be positive." .format (name )
183- )
223+ # (4) Order here is important: versiondict overwrites dictargs
224+ versiondict = {** dictargs , ** versiondict } # type: ignore
184225
185- self ._major = version_parts ["major" ]
186- self ._minor = version_parts ["minor" ]
187- self ._patch = version_parts ["patch" ]
188- self ._prerelease = cls ._enforce_str (prerelease or verlist [3 ])
189- self ._build = cls ._enforce_str (build or verlist [4 ])
226+ # (5) Set all version components:
227+ self ._major = cls ._ensure_int (
228+ cast (StringOrInt , versiondict .get ("major" , cls .VERSIONPARTDEFAULTS [0 ]))
229+ )
230+ self ._minor = cls ._ensure_int (
231+ cast (StringOrInt , versiondict .get ("minor" , cls .VERSIONPARTDEFAULTS [1 ]))
232+ )
233+ self ._patch = cls ._ensure_int (
234+ cast (StringOrInt , versiondict .get ("patch" , cls .VERSIONPARTDEFAULTS [2 ]))
235+ )
236+ self ._prerelease = cls ._enforce_str (
237+ cast (
238+ Optional [StringOrInt ],
239+ versiondict .get ("prerelease" , cls .VERSIONPARTDEFAULTS [3 ]),
240+ )
241+ )
242+ self ._build = cls ._enforce_str (
243+ cast (
244+ Optional [StringOrInt ],
245+ versiondict .get ("build" , cls .VERSIONPARTDEFAULTS [4 ]),
246+ )
247+ )
190248
191249 @classmethod
192250 def _nat_cmp (cls , a , b ): # TODO: type hints
@@ -211,6 +269,31 @@ def cmp_prerelease_tag(a, b):
211269 else :
212270 return _cmp (len (a ), len (b ))
213271
272+ @classmethod
273+ def _ensure_int (cls , value : StringOrInt ) -> int :
274+ """
275+ Ensures integer value type regardless if argument type is str or bytes.
276+ Otherwise raise ValueError.
277+
278+ :param value:
279+ :raises ValueError: Two conditions:
280+ * If value is not an integer or cannot be converted.
281+ * If value is negative.
282+ :return: the converted value as integer
283+ """
284+ try :
285+ value = int (value )
286+ except ValueError :
287+ raise ValueError (
288+ "Expected integer or integer string for major, minor, or patch"
289+ )
290+
291+ if value < 0 :
292+ raise ValueError (
293+ f"Argument { value } is negative. A version can only be positive."
294+ )
295+ return value
296+
214297 @classmethod
215298 def _enforce_str (cls , s : Optional [StringOrInt ]) -> Optional [str ]:
216299 """
@@ -462,8 +545,12 @@ def compare(self, other: Comparable) -> int:
462545 0
463546 """
464547 cls = type (self )
548+
549+ # See https://github.com/python/mypy/issues/4019
465550 if isinstance (other , String .__args__ ): # type: ignore
466- other = cls .parse (other )
551+ if "." not in cast (str , cls ._ensure_str (other )):
552+ raise ValueError ("Expected semver version string." )
553+ other = cls (other )
467554 elif isinstance (other , dict ):
468555 other = cls (** other )
469556 elif isinstance (other , (tuple , list )):
0 commit comments