gmake_python_helpers

Helpers for using Python venv/pip-tools/black/pylama/... with GNU make
git clone https://ccx.te2000.cz/git/gmake_python_helpers
Log | Files | Refs | README

version.py (16676B)


      1 # This file is dual licensed under the terms of the Apache License, Version
      2 # 2.0, and the BSD License. See the LICENSE file in the root of this repository
      3 # for complete details.
      4 """
      5 .. testsetup::
      6 
      7     from packaging.version import parse, Version
      8 """
      9 
     10 from __future__ import annotations
     11 
     12 import itertools
     13 import re
     14 from typing import Any, Callable, NamedTuple, SupportsInt, Tuple, Union
     15 
     16 from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType
     17 
     18 __all__ = ["VERSION_PATTERN", "InvalidVersion", "Version", "parse"]
     19 
     20 LocalType = Tuple[Union[int, str], ...]
     21 
     22 CmpPrePostDevType = Union[InfinityType, NegativeInfinityType, Tuple[str, int]]
     23 CmpLocalType = Union[
     24     NegativeInfinityType,
     25     Tuple[Union[Tuple[int, str], Tuple[NegativeInfinityType, Union[int, str]]], ...],
     26 ]
     27 CmpKey = Tuple[
     28     int,
     29     Tuple[int, ...],
     30     CmpPrePostDevType,
     31     CmpPrePostDevType,
     32     CmpPrePostDevType,
     33     CmpLocalType,
     34 ]
     35 VersionComparisonMethod = Callable[[CmpKey, CmpKey], bool]
     36 
     37 
     38 class _Version(NamedTuple):
     39     epoch: int
     40     release: tuple[int, ...]
     41     dev: tuple[str, int] | None
     42     pre: tuple[str, int] | None
     43     post: tuple[str, int] | None
     44     local: LocalType | None
     45 
     46 
     47 def parse(version: str) -> Version:
     48     """Parse the given version string.
     49 
     50     >>> parse('1.0.dev1')
     51     <Version('1.0.dev1')>
     52 
     53     :param version: The version string to parse.
     54     :raises InvalidVersion: When the version string is not a valid version.
     55     """
     56     return Version(version)
     57 
     58 
     59 class InvalidVersion(ValueError):
     60     """Raised when a version string is not a valid version.
     61 
     62     >>> Version("invalid")
     63     Traceback (most recent call last):
     64         ...
     65     packaging.version.InvalidVersion: Invalid version: 'invalid'
     66     """
     67 
     68 
     69 class _BaseVersion:
     70     _key: tuple[Any, ...]
     71 
     72     def __hash__(self) -> int:
     73         return hash(self._key)
     74 
     75     # Please keep the duplicated `isinstance` check
     76     # in the six comparisons hereunder
     77     # unless you find a way to avoid adding overhead function calls.
     78     def __lt__(self, other: _BaseVersion) -> bool:
     79         if not isinstance(other, _BaseVersion):
     80             return NotImplemented
     81 
     82         return self._key < other._key
     83 
     84     def __le__(self, other: _BaseVersion) -> bool:
     85         if not isinstance(other, _BaseVersion):
     86             return NotImplemented
     87 
     88         return self._key <= other._key
     89 
     90     def __eq__(self, other: object) -> bool:
     91         if not isinstance(other, _BaseVersion):
     92             return NotImplemented
     93 
     94         return self._key == other._key
     95 
     96     def __ge__(self, other: _BaseVersion) -> bool:
     97         if not isinstance(other, _BaseVersion):
     98             return NotImplemented
     99 
    100         return self._key >= other._key
    101 
    102     def __gt__(self, other: _BaseVersion) -> bool:
    103         if not isinstance(other, _BaseVersion):
    104             return NotImplemented
    105 
    106         return self._key > other._key
    107 
    108     def __ne__(self, other: object) -> bool:
    109         if not isinstance(other, _BaseVersion):
    110             return NotImplemented
    111 
    112         return self._key != other._key
    113 
    114 
    115 # Deliberately not anchored to the start and end of the string, to make it
    116 # easier for 3rd party code to reuse
    117 _VERSION_PATTERN = r"""
    118     v?
    119     (?:
    120         (?:(?P<epoch>[0-9]+)!)?                           # epoch
    121         (?P<release>[0-9]+(?:\.[0-9]+)*)                  # release segment
    122         (?P<pre>                                          # pre-release
    123             [-_\.]?
    124             (?P<pre_l>alpha|a|beta|b|preview|pre|c|rc)
    125             [-_\.]?
    126             (?P<pre_n>[0-9]+)?
    127         )?
    128         (?P<post>                                         # post release
    129             (?:-(?P<post_n1>[0-9]+))
    130             |
    131             (?:
    132                 [-_\.]?
    133                 (?P<post_l>post|rev|r)
    134                 [-_\.]?
    135                 (?P<post_n2>[0-9]+)?
    136             )
    137         )?
    138         (?P<dev>                                          # dev release
    139             [-_\.]?
    140             (?P<dev_l>dev)
    141             [-_\.]?
    142             (?P<dev_n>[0-9]+)?
    143         )?
    144     )
    145     (?:\+(?P<local>[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
    146 """
    147 
    148 VERSION_PATTERN = _VERSION_PATTERN
    149 """
    150 A string containing the regular expression used to match a valid version.
    151 
    152 The pattern is not anchored at either end, and is intended for embedding in larger
    153 expressions (for example, matching a version number as part of a file name). The
    154 regular expression should be compiled with the ``re.VERBOSE`` and ``re.IGNORECASE``
    155 flags set.
    156 
    157 :meta hide-value:
    158 """
    159 
    160 
    161 class Version(_BaseVersion):
    162     """This class abstracts handling of a project's versions.
    163 
    164     A :class:`Version` instance is comparison aware and can be compared and
    165     sorted using the standard Python interfaces.
    166 
    167     >>> v1 = Version("1.0a5")
    168     >>> v2 = Version("1.0")
    169     >>> v1
    170     <Version('1.0a5')>
    171     >>> v2
    172     <Version('1.0')>
    173     >>> v1 < v2
    174     True
    175     >>> v1 == v2
    176     False
    177     >>> v1 > v2
    178     False
    179     >>> v1 >= v2
    180     False
    181     >>> v1 <= v2
    182     True
    183     """
    184 
    185     _regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
    186     _key: CmpKey
    187 
    188     def __init__(self, version: str) -> None:
    189         """Initialize a Version object.
    190 
    191         :param version:
    192             The string representation of a version which will be parsed and normalized
    193             before use.
    194         :raises InvalidVersion:
    195             If the ``version`` does not conform to PEP 440 in any way then this
    196             exception will be raised.
    197         """
    198 
    199         # Validate the version and parse it into pieces
    200         match = self._regex.search(version)
    201         if not match:
    202             raise InvalidVersion(f"Invalid version: {version!r}")
    203 
    204         # Store the parsed out pieces of the version
    205         self._version = _Version(
    206             epoch=int(match.group("epoch")) if match.group("epoch") else 0,
    207             release=tuple(int(i) for i in match.group("release").split(".")),
    208             pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
    209             post=_parse_letter_version(
    210                 match.group("post_l"), match.group("post_n1") or match.group("post_n2")
    211             ),
    212             dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
    213             local=_parse_local_version(match.group("local")),
    214         )
    215 
    216         # Generate a key which will be used for sorting
    217         self._key = _cmpkey(
    218             self._version.epoch,
    219             self._version.release,
    220             self._version.pre,
    221             self._version.post,
    222             self._version.dev,
    223             self._version.local,
    224         )
    225 
    226     def __repr__(self) -> str:
    227         """A representation of the Version that shows all internal state.
    228 
    229         >>> Version('1.0.0')
    230         <Version('1.0.0')>
    231         """
    232         return f"<Version('{self}')>"
    233 
    234     def __str__(self) -> str:
    235         """A string representation of the version that can be round-tripped.
    236 
    237         >>> str(Version("1.0a5"))
    238         '1.0a5'
    239         """
    240         parts = []
    241 
    242         # Epoch
    243         if self.epoch != 0:
    244             parts.append(f"{self.epoch}!")
    245 
    246         # Release segment
    247         parts.append(".".join(str(x) for x in self.release))
    248 
    249         # Pre-release
    250         if self.pre is not None:
    251             parts.append("".join(str(x) for x in self.pre))
    252 
    253         # Post-release
    254         if self.post is not None:
    255             parts.append(f".post{self.post}")
    256 
    257         # Development release
    258         if self.dev is not None:
    259             parts.append(f".dev{self.dev}")
    260 
    261         # Local version segment
    262         if self.local is not None:
    263             parts.append(f"+{self.local}")
    264 
    265         return "".join(parts)
    266 
    267     @property
    268     def epoch(self) -> int:
    269         """The epoch of the version.
    270 
    271         >>> Version("2.0.0").epoch
    272         0
    273         >>> Version("1!2.0.0").epoch
    274         1
    275         """
    276         return self._version.epoch
    277 
    278     @property
    279     def release(self) -> tuple[int, ...]:
    280         """The components of the "release" segment of the version.
    281 
    282         >>> Version("1.2.3").release
    283         (1, 2, 3)
    284         >>> Version("2.0.0").release
    285         (2, 0, 0)
    286         >>> Version("1!2.0.0.post0").release
    287         (2, 0, 0)
    288 
    289         Includes trailing zeroes but not the epoch or any pre-release / development /
    290         post-release suffixes.
    291         """
    292         return self._version.release
    293 
    294     @property
    295     def pre(self) -> tuple[str, int] | None:
    296         """The pre-release segment of the version.
    297 
    298         >>> print(Version("1.2.3").pre)
    299         None
    300         >>> Version("1.2.3a1").pre
    301         ('a', 1)
    302         >>> Version("1.2.3b1").pre
    303         ('b', 1)
    304         >>> Version("1.2.3rc1").pre
    305         ('rc', 1)
    306         """
    307         return self._version.pre
    308 
    309     @property
    310     def post(self) -> int | None:
    311         """The post-release number of the version.
    312 
    313         >>> print(Version("1.2.3").post)
    314         None
    315         >>> Version("1.2.3.post1").post
    316         1
    317         """
    318         return self._version.post[1] if self._version.post else None
    319 
    320     @property
    321     def dev(self) -> int | None:
    322         """The development number of the version.
    323 
    324         >>> print(Version("1.2.3").dev)
    325         None
    326         >>> Version("1.2.3.dev1").dev
    327         1
    328         """
    329         return self._version.dev[1] if self._version.dev else None
    330 
    331     @property
    332     def local(self) -> str | None:
    333         """The local version segment of the version.
    334 
    335         >>> print(Version("1.2.3").local)
    336         None
    337         >>> Version("1.2.3+abc").local
    338         'abc'
    339         """
    340         if self._version.local:
    341             return ".".join(str(x) for x in self._version.local)
    342         else:
    343             return None
    344 
    345     @property
    346     def public(self) -> str:
    347         """The public portion of the version.
    348 
    349         >>> Version("1.2.3").public
    350         '1.2.3'
    351         >>> Version("1.2.3+abc").public
    352         '1.2.3'
    353         >>> Version("1!1.2.3dev1+abc").public
    354         '1!1.2.3.dev1'
    355         """
    356         return str(self).split("+", 1)[0]
    357 
    358     @property
    359     def base_version(self) -> str:
    360         """The "base version" of the version.
    361 
    362         >>> Version("1.2.3").base_version
    363         '1.2.3'
    364         >>> Version("1.2.3+abc").base_version
    365         '1.2.3'
    366         >>> Version("1!1.2.3dev1+abc").base_version
    367         '1!1.2.3'
    368 
    369         The "base version" is the public version of the project without any pre or post
    370         release markers.
    371         """
    372         parts = []
    373 
    374         # Epoch
    375         if self.epoch != 0:
    376             parts.append(f"{self.epoch}!")
    377 
    378         # Release segment
    379         parts.append(".".join(str(x) for x in self.release))
    380 
    381         return "".join(parts)
    382 
    383     @property
    384     def is_prerelease(self) -> bool:
    385         """Whether this version is a pre-release.
    386 
    387         >>> Version("1.2.3").is_prerelease
    388         False
    389         >>> Version("1.2.3a1").is_prerelease
    390         True
    391         >>> Version("1.2.3b1").is_prerelease
    392         True
    393         >>> Version("1.2.3rc1").is_prerelease
    394         True
    395         >>> Version("1.2.3dev1").is_prerelease
    396         True
    397         """
    398         return self.dev is not None or self.pre is not None
    399 
    400     @property
    401     def is_postrelease(self) -> bool:
    402         """Whether this version is a post-release.
    403 
    404         >>> Version("1.2.3").is_postrelease
    405         False
    406         >>> Version("1.2.3.post1").is_postrelease
    407         True
    408         """
    409         return self.post is not None
    410 
    411     @property
    412     def is_devrelease(self) -> bool:
    413         """Whether this version is a development release.
    414 
    415         >>> Version("1.2.3").is_devrelease
    416         False
    417         >>> Version("1.2.3.dev1").is_devrelease
    418         True
    419         """
    420         return self.dev is not None
    421 
    422     @property
    423     def major(self) -> int:
    424         """The first item of :attr:`release` or ``0`` if unavailable.
    425 
    426         >>> Version("1.2.3").major
    427         1
    428         """
    429         return self.release[0] if len(self.release) >= 1 else 0
    430 
    431     @property
    432     def minor(self) -> int:
    433         """The second item of :attr:`release` or ``0`` if unavailable.
    434 
    435         >>> Version("1.2.3").minor
    436         2
    437         >>> Version("1").minor
    438         0
    439         """
    440         return self.release[1] if len(self.release) >= 2 else 0
    441 
    442     @property
    443     def micro(self) -> int:
    444         """The third item of :attr:`release` or ``0`` if unavailable.
    445 
    446         >>> Version("1.2.3").micro
    447         3
    448         >>> Version("1").micro
    449         0
    450         """
    451         return self.release[2] if len(self.release) >= 3 else 0
    452 
    453 
    454 class _TrimmedRelease(Version):
    455     @property
    456     def release(self) -> tuple[int, ...]:
    457         """
    458         Release segment without any trailing zeros.
    459 
    460         >>> _TrimmedRelease('1.0.0').release
    461         (1,)
    462         >>> _TrimmedRelease('0.0').release
    463         (0,)
    464         """
    465         rel = super().release
    466         nonzeros = (index for index, val in enumerate(rel) if val)
    467         last_nonzero = max(nonzeros, default=0)
    468         return rel[: last_nonzero + 1]
    469 
    470 
    471 def _parse_letter_version(
    472     letter: str | None, number: str | bytes | SupportsInt | None
    473 ) -> tuple[str, int] | None:
    474     if letter:
    475         # We consider there to be an implicit 0 in a pre-release if there is
    476         # not a numeral associated with it.
    477         if number is None:
    478             number = 0
    479 
    480         # We normalize any letters to their lower case form
    481         letter = letter.lower()
    482 
    483         # We consider some words to be alternate spellings of other words and
    484         # in those cases we want to normalize the spellings to our preferred
    485         # spelling.
    486         if letter == "alpha":
    487             letter = "a"
    488         elif letter == "beta":
    489             letter = "b"
    490         elif letter in ["c", "pre", "preview"]:
    491             letter = "rc"
    492         elif letter in ["rev", "r"]:
    493             letter = "post"
    494 
    495         return letter, int(number)
    496 
    497     assert not letter
    498     if number:
    499         # We assume if we are given a number, but we are not given a letter
    500         # then this is using the implicit post release syntax (e.g. 1.0-1)
    501         letter = "post"
    502 
    503         return letter, int(number)
    504 
    505     return None
    506 
    507 
    508 _local_version_separators = re.compile(r"[\._-]")
    509 
    510 
    511 def _parse_local_version(local: str | None) -> LocalType | None:
    512     """
    513     Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
    514     """
    515     if local is not None:
    516         return tuple(
    517             part.lower() if not part.isdigit() else int(part)
    518             for part in _local_version_separators.split(local)
    519         )
    520     return None
    521 
    522 
    523 def _cmpkey(
    524     epoch: int,
    525     release: tuple[int, ...],
    526     pre: tuple[str, int] | None,
    527     post: tuple[str, int] | None,
    528     dev: tuple[str, int] | None,
    529     local: LocalType | None,
    530 ) -> CmpKey:
    531     # When we compare a release version, we want to compare it with all of the
    532     # trailing zeros removed. So we'll use a reverse the list, drop all the now
    533     # leading zeros until we come to something non zero, then take the rest
    534     # re-reverse it back into the correct order and make it a tuple and use
    535     # that for our sorting key.
    536     _release = tuple(
    537         reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
    538     )
    539 
    540     # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
    541     # We'll do this by abusing the pre segment, but we _only_ want to do this
    542     # if there is not a pre or a post segment. If we have one of those then
    543     # the normal sorting rules will handle this case correctly.
    544     if pre is None and post is None and dev is not None:
    545         _pre: CmpPrePostDevType = NegativeInfinity
    546     # Versions without a pre-release (except as noted above) should sort after
    547     # those with one.
    548     elif pre is None:
    549         _pre = Infinity
    550     else:
    551         _pre = pre
    552 
    553     # Versions without a post segment should sort before those with one.
    554     if post is None:
    555         _post: CmpPrePostDevType = NegativeInfinity
    556 
    557     else:
    558         _post = post
    559 
    560     # Versions without a development segment should sort after those with one.
    561     if dev is None:
    562         _dev: CmpPrePostDevType = Infinity
    563 
    564     else:
    565         _dev = dev
    566 
    567     if local is None:
    568         # Versions without a local segment should sort before those with one.
    569         _local: CmpLocalType = NegativeInfinity
    570     else:
    571         # Versions with a local segment need that segment parsed to implement
    572         # the sorting rules in PEP440.
    573         # - Alpha numeric segments sort before numeric segments
    574         # - Alpha numeric segments sort lexicographically
    575         # - Numeric segments sort numerically
    576         # - Shorter versions sort before longer versions when the prefixes
    577         #   match exactly
    578         _local = tuple(
    579             (i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
    580         )
    581 
    582     return epoch, _release, _pre, _post, _dev, _local