11"""Version handling by a semver compatible version class."""
22
3+ # from ast import operator
34import re
45from functools import wraps
56from typing import (
1516 cast ,
1617 Callable ,
1718 Collection ,
19+ Match
1820 Type ,
1921 TypeVar ,
2022)
@@ -76,6 +78,10 @@ class Version:
7678 #: The names of the different parts of a version
7779 NAMES : ClassVar [Tuple [str , ...]] = tuple ([item [1 :] for item in __slots__ ])
7880
81+ #:
82+ _RE_NUMBER = r"0|[1-9]\d*"
83+
84+
7985 #: Regex for number in a prerelease
8086 _LAST_NUMBER : ClassVar [Pattern [str ]] = re .compile (r"(?:[^\d]*(\d+)[^\d]*)+" )
8187 #: Regex template for a semver version
@@ -113,6 +119,14 @@ class Version:
113119 re .VERBOSE ,
114120 )
115121
122+ #: The default prefix for the prerelease part.
123+ #: Used in :meth:`Version.bump_prerelease`.
124+ default_prerelease_prefix = "rc"
125+
126+ #: The default prefix for the build part
127+ #: Used in :meth:`Version.bump_build`.
128+ default_build_prefix = "build"
129+
116130 def __init__ (
117131 self ,
118132 major : SupportsInt ,
@@ -382,22 +396,21 @@ def compare(self, other: Comparable) -> int:
382396 :return: The return value is negative if ver1 < ver2,
383397 zero if ver1 == ver2 and strictly positive if ver1 > ver2
384398
385- >>> semver.compare("2.0.0")
399+ >>> ver = semver.Version.parse("3.4.5")
400+ >>> ver.compare("4.0.0")
386401 -1
387- >>> semver .compare("1 .0.0")
402+ >>> ver .compare("3 .0.0")
388403 1
389- >>> semver.compare("2.0.0")
390- 0
391- >>> semver.compare(dict(major=2, minor=0, patch=0))
404+ >>> ver.compare("3.4.5")
392405 0
393406 """
394407 cls = type (self )
395408 if isinstance (other , String .__args__ ): # type: ignore
396- other = cls .parse (other )
409+ other = cls .parse (other ) # type: ignore
397410 elif isinstance (other , dict ):
398- other = cls (** other )
411+ other = cls (** other ) # type: ignore
399412 elif isinstance (other , (tuple , list )):
400- other = cls (* other )
413+ other = cls (* other ) # type: ignore
401414 elif not isinstance (other , cls ):
402415 raise TypeError (
403416 f"Expected str, bytes, dict, tuple, list, or { cls .__name__ } instance, "
@@ -555,25 +568,19 @@ def finalize_version(self) -> "Version":
555568 cls = type (self )
556569 return cls (self .major , self .minor , self .patch )
557570
558- def match (self , match_expr : str ) -> bool :
571+ def _match (self , match_expr : str ) -> bool :
559572 """
560573 Compare self to match a match expression.
561574
562575 :param match_expr: optional operator and version; valid operators are
563- ``<`` smaller than
576+ ``<``` smaller than
564577 ``>`` greater than
565578 ``>=`` greator or equal than
566579 ``<=`` smaller or equal than
567580 ``==`` equal
568581 ``!=`` not equal
582+ ``~=`` compatible release clause
569583 :return: True if the expression matches the version, otherwise False
570-
571- >>> semver.Version.parse("2.0.0").match(">=1.0.0")
572- True
573- >>> semver.Version.parse("1.0.0").match(">1.0.0")
574- False
575- >>> semver.Version.parse("4.0.4").match("4.0.4")
576- True
577584 """
578585 prefix = match_expr [:2 ]
579586 if prefix in (">=" , "<=" , "==" , "!=" ):
@@ -588,7 +595,7 @@ def match(self, match_expr: str) -> bool:
588595 raise ValueError (
589596 "match_expr parameter should be in format <op><ver>, "
590597 "where <op> is one of "
591- "['<', '>', '==', '<=', '>=', '!=']. "
598+ "['<', '>', '==', '<=', '>=', '!=', '~=' ]. "
592599 "You provided: %r" % match_expr
593600 )
594601
@@ -606,6 +613,119 @@ def match(self, match_expr: str) -> bool:
606613
607614 return cmp_res in possibilities
608615
616+ def match (self , match_expr : str ) -> bool :
617+ """Compare self to match a match expression.
618+
619+ :param match_expr: optional operator and version; valid operators are
620+ ``<``` smaller than
621+ ``>`` greater than
622+ ``>=`` greator or equal than
623+ ``<=`` smaller or equal than
624+ ``==`` equal
625+ ``!=`` not equal
626+ ``~=`` compatible release clause
627+ :return: True if the expression matches the version, otherwise False
628+ """
629+ # TODO: The following function should be better
630+ # integrated into a special Spec class
631+ def compare_eq (index , other ) -> bool :
632+ return self [:index ] == other [:index ]
633+
634+ def compare_ne (index , other ) -> bool :
635+ return not compare_eq (index , other )
636+
637+ def compare_lt (index , other ) -> bool :
638+ return self [:index ] < other [:index ]
639+
640+ def compare_gt (index , other ) -> bool :
641+ return not compare_lt (index , other )
642+
643+ def compare_le (index , other ) -> bool :
644+ return self [:index ] <= other [:index ]
645+
646+ def compare_ge (index , other ) -> bool :
647+ return self [:index ] >= other [:index ]
648+
649+ def compare_compatible (index , other ) -> bool :
650+ return compare_gt (index , other ) and compare_eq (index , other )
651+
652+ op_table : Dict [str , Callable [[int , Tuple ], bool ]] = {
653+ '==' : compare_eq ,
654+ '!=' : compare_ne ,
655+ '<' : compare_lt ,
656+ '>' : compare_gt ,
657+ '<=' : compare_le ,
658+ '>=' : compare_ge ,
659+ '~=' : compare_compatible ,
660+ }
661+
662+ regex = r"""(?P<operator>[<]|[>]|<=|>=|~=|==|!=)?
663+ (?P<version>
664+ (?P<major>0|[1-9]\d*)
665+ (?:\.(?P<minor>\*|0|[1-9]\d*)
666+ (?:\.(?P<patch>\*|0|[1-9]\d*))?
667+ )?
668+ )"""
669+ match = re .match (regex , match_expr , re .VERBOSE )
670+ if match is None :
671+ raise ValueError (
672+ "match_expr parameter should be in format <op><ver>, "
673+ "where <op> is one of %s. "
674+ "<ver> is a version string like '1.2.3' or '1.*' "
675+ "You provided: %r" % (list (op_table .keys ()), match_expr )
676+ )
677+ match_version = match ["version" ]
678+ operator = cast (Dict , match ).get ('operator' , '==' )
679+
680+ if "*" not in match_version :
681+ # conventional compare
682+ possibilities_dict = {
683+ ">" : (1 ,),
684+ "<" : (- 1 ,),
685+ "==" : (0 ,),
686+ "!=" : (- 1 , 1 ),
687+ ">=" : (0 , 1 ),
688+ "<=" : (- 1 , 0 ),
689+ }
690+
691+ possibilities = possibilities_dict [operator ]
692+ cmp_res = self .compare (match_version )
693+
694+ return cmp_res in possibilities
695+
696+ # Advanced compare with "*" like "<=1.2.*"
697+ # Algorithm:
698+ # TL;DR: Delegate the comparison to tuples
699+ #
700+ # 1. Create a tuple of the string with major, minor, and path
701+ # unless one of them is None
702+ # 2. Determine the position of the first "*" in the tuple from step 1
703+ # 3. Extract the matched operators
704+ # 4. Look up the function in the operator table
705+ # 5. Call the found function and pass the index (step 2) and
706+ # the tuple (step 1)
707+ # 6. Compare the both tuples up to the position of index
708+ # For example, if you have (1, 2, "*") and self is
709+ # (1, 2, 3, None, None), you compare (1, 2) <OPERATOR> (1, 2)
710+ # 7. Return the result of the comparison
711+ match_version = tuple ([match [item ]
712+ for item in ('major' , 'minor' , 'patch' )
713+ if item is not None
714+ ]
715+ )
716+
717+ try :
718+ index = match_version .index ("*" )
719+ except ValueError :
720+ index = None
721+
722+ if not index :
723+ raise ValueError ("Major version cannot be set to '*'" )
724+
725+ # At this point, only valid operators should be available
726+ func : Callable [[int , Tuple ], bool ] = op_table [operator ]
727+ return func (index , match_version )
728+
609729 @classmethod
610730 def parse (
611731 cls : Type [T ], version : String , optional_minor_and_patch : bool = False
0 commit comments