@@ -62,7 +62,7 @@ def wrapper(*args: object, **kwargs: object) -> object:
6262 "r" : "post" ,
6363}
6464
65- __all__ = ["VERSION_PATTERN" , "InvalidVersion" , "Version" , "parse" ]
65+ __all__ = ["VERSION_PATTERN" , "InvalidVersion" , "Version" , "normalize_pre" , " parse" ]
6666
6767
6868def __dir__ () -> list [str ]:
@@ -90,12 +90,29 @@ def __dir__() -> list[str]:
9090class _VersionReplace (TypedDict , total = False ):
9191 epoch : int | None
9292 release : tuple [int , ...] | None
93- pre : tuple [Literal [ "a" , "b" , "rc" ] , int ] | None
93+ pre : tuple [str , int ] | None
9494 post : int | None
9595 dev : int | None
9696 local : str | None
9797
9898
99+ def normalize_pre (letter : str , / ) -> str :
100+ """Normalize the pre-release segment of a version string.
101+
102+ Returns a lowercase version of the string if not a known pre-release
103+ identifier.
104+
105+ >>> normalize_pre('alpha')
106+ 'a'
107+ >>> normalize_pre('BETA')
108+ 'b'
109+ >>> normalize_pre('rc')
110+ 'rc'
111+ """
112+ letter = letter .lower ()
113+ return _LETTER_NORMALIZATION .get (letter , letter )
114+
115+
99116def parse (version : str ) -> Version :
100117 """Parse the given version string.
101118
@@ -263,14 +280,12 @@ def _validate_release(value: object, /) -> tuple[int, ...]:
263280def _validate_pre (value : object , / ) -> tuple [Literal ["a" , "b" , "rc" ], int ] | None :
264281 if value is None :
265282 return value
266- if (
267- isinstance (value , tuple )
268- and len (value ) == 2
269- and value [0 ] in ("a" , "b" , "rc" )
270- and isinstance (value [1 ], int )
271- and value [1 ] >= 0
272- ):
273- return value
283+ if isinstance (value , tuple ) and len (value ) == 2 :
284+ letter , number = value
285+ letter = normalize_pre (letter )
286+ if letter in {"a" , "b" , "rc" } and isinstance (number , int ) and number >= 0 :
287+ # type checkers can't infer the Literal type here on letter
288+ return (letter , number ) # type: ignore[return-value]
274289 msg = f"pre must be a tuple of ('a'|'b'|'rc', non-negative int), got { value } "
275290 raise InvalidVersion (msg )
276291
@@ -306,9 +321,9 @@ def _validate_local(value: object, /) -> LocalType | None:
306321class _Version (NamedTuple ):
307322 epoch : int
308323 release : tuple [int , ...]
309- dev : tuple [str , int ] | None
310- pre : tuple [str , int ] | None
311- post : tuple [str , int ] | None
324+ dev : tuple [Literal [ "dev" ] , int ] | None
325+ pre : tuple [Literal [ "a" , "b" , "rc" ] , int ] | None
326+ post : tuple [Literal [ "post" ] , int ] | None
312327 local : LocalType | None
313328
314329
@@ -343,9 +358,9 @@ class Version(_BaseVersion):
343358
344359 _epoch : int
345360 _release : tuple [int , ...]
346- _dev : tuple [str , int ] | None
347- _pre : tuple [str , int ] | None
348- _post : tuple [str , int ] | None
361+ _dev : tuple [Literal [ "dev" ] , int ] | None
362+ _pre : tuple [Literal [ "a" , "b" , "rc" ] , int ] | None
363+ _post : tuple [Literal [ "post" ] , int ] | None
349364 _local : LocalType | None
350365
351366 _key_cache : CmpKey | None
@@ -366,16 +381,47 @@ def __init__(self, version: str) -> None:
366381 raise InvalidVersion (f"Invalid version: { version !r} " )
367382 self ._epoch = int (match .group ("epoch" )) if match .group ("epoch" ) else 0
368383 self ._release = tuple (map (int , match .group ("release" ).split ("." )))
369- self ._pre = _parse_letter_version (match .group ("pre_l" ), match .group ("pre_n" ))
370- self ._post = _parse_letter_version (
384+ # We can type ignore the assignments below because the regex guarantees
385+ # the correct strings
386+ self ._pre = _parse_letter_version (match .group ("pre_l" ), match .group ("pre_n" )) # type: ignore[assignment]
387+ self ._post = _parse_letter_version ( # type: ignore[assignment]
371388 match .group ("post_l" ), match .group ("post_n1" ) or match .group ("post_n2" )
372389 )
373- self ._dev = _parse_letter_version (match .group ("dev_l" ), match .group ("dev_n" ))
390+ self ._dev = _parse_letter_version (match .group ("dev_l" ), match .group ("dev_n" )) # type: ignore[assignment]
374391 self ._local = _parse_local_version (match .group ("local" ))
375392
376393 # Key which will be used for sorting
377394 self ._key_cache = None
378395
396+ @classmethod
397+ def from_parts (
398+ cls ,
399+ * ,
400+ epoch : int = 0 ,
401+ release : tuple [int , ...],
402+ pre : tuple [str , int ] | None = None ,
403+ post : int | None = None ,
404+ dev : int | None = None ,
405+ local : str | None = None ,
406+ ) -> Self :
407+ _epoch = _validate_epoch (epoch )
408+ _release = _validate_release (release )
409+ _pre = _validate_pre (pre ) if pre is not None else None
410+ _post = _validate_post (post ) if post is not None else None
411+ _dev = _validate_dev (dev ) if dev is not None else None
412+ _local = _validate_local (local ) if local is not None else None
413+
414+ new_version = cls .__new__ (cls )
415+ new_version ._key_cache = None
416+ new_version ._epoch = _epoch
417+ new_version ._release = _release
418+ new_version ._pre = _pre
419+ new_version ._post = _post
420+ new_version ._dev = _dev
421+ new_version ._local = _local
422+
423+ return new_version
424+
379425 def __replace__ (self , ** kwargs : Unpack [_VersionReplace ]) -> Self :
380426 epoch = _validate_epoch (kwargs ["epoch" ]) if "epoch" in kwargs else self ._epoch
381427 release = (
@@ -512,7 +558,7 @@ def release(self) -> tuple[int, ...]:
512558 return self ._release
513559
514560 @property
515- def pre (self ) -> tuple [str , int ] | None :
561+ def pre (self ) -> tuple [Literal [ "a" , "b" , "rc" ] , int ] | None :
516562 """The pre-release segment of the version.
517563
518564 >>> print(Version("1.2.3").pre)
0 commit comments