-
-
Notifications
You must be signed in to change notification settings - Fork 1
/
version.py
170 lines (137 loc) · 5.11 KB
/
version.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
"""
Semantic version parsing and data structure.
"""
import re
from dataclasses import dataclass
from functools import cached_property, total_ordering
from typing import Union
from .compat import TypeAlias
# Implementation based on python-semanticverison, which is distributed under two-clause BSD license.
_IDENTIFIER_CHARS = "0-9A-Za-z-"
_IDENTIFIER_REGEX = f"[{_IDENTIFIER_CHARS}]+"
_VERSION_REGEX = (
r"^(?P<major>\d+)\."
r"(?P<minor>\d+)\."
r"(?P<patch>\d+)"
rf"(?:-(?P<prerelease>[{_IDENTIFIER_CHARS}.]+))?"
rf"(?:\+(?P<build>[{_IDENTIFIER_CHARS}.]+))?$"
)
class _Max:
def __eq__(self, other: object) -> bool:
if isinstance(other, _Max):
return True
return NotImplemented
@dataclass
class _Numeric:
value: int
def __lt__(self, other: object) -> bool:
if isinstance(other, _Numeric):
return self.value < other.value
if isinstance(other, (_Max, _Alpha)):
return True
return NotImplemented
@dataclass
class _Alpha:
value: str
def __lt__(self, other: object) -> bool:
if isinstance(other, _Alpha):
return self.value < other.value
if isinstance(other, _Numeric):
return False
if isinstance(other, _Max):
return True
return NotImplemented
OrderingValue: TypeAlias = Union[int, _Max, _Numeric, _Alpha]
@total_ordering
@dataclass(frozen=True)
class Version:
major: int
minor: int
patch: int
prerelease: tuple[str, ...] = ()
build: tuple[str, ...] = ()
def __post_init__(self) -> None:
_validate_number(self.major, "major")
_validate_number(self.minor, "minor")
_validate_number(self.patch, "patch")
_validate_tuple(self.prerelease, "prerelease", forbid_numeric_leading_zero=True)
_validate_tuple(self.build, "build", forbid_numeric_leading_zero=False)
def __str__(self) -> str:
value = f"{self.major}.{self.minor}.{self.patch}"
if self.prerelease:
value = f"{value}-{'.'.join(self.prerelease)}"
if self.build:
value = f"{value}+{'.'.join(self.build)}"
return value
def __lt__(self, other: object) -> bool:
if not isinstance(other, Version):
return NotImplemented
return self._ordering_key < other._ordering_key
@cached_property
def _ordering_key(self) -> tuple[OrderingValue, ...]:
values: list[OrderingValue] = [self.major, self.minor, self.patch]
if len(self.prerelease) == 0:
values.append(_Max())
else:
for identifier in self.prerelease:
values.append(
_Numeric(int(identifier))
if identifier.isdigit()
else _Alpha(identifier)
)
return tuple(values)
def next_major(self) -> "Version":
return Version(major=self.major + 1, minor=0, patch=0, prerelease=(), build=())
def next_minor(self) -> "Version":
return Version(
major=self.major, minor=self.minor + 1, patch=0, prerelease=(), build=()
)
def next_patch(self) -> "Version":
return Version(
major=self.major,
minor=self.minor,
patch=self.patch + 1,
prerelease=(),
build=(),
)
@classmethod
def parse(cls, version_text: str) -> "Version":
match = re.match(_VERSION_REGEX, version_text)
if match is None:
raise ValueError(f"Version text could not be parsed: {version_text}")
return cls(
major=_validate_number_group(match, "major"),
minor=_validate_number_group(match, "minor"),
patch=_validate_number_group(match, "patch"),
prerelease=_group_to_tuple(match, "prerelease"),
build=_group_to_tuple(match, "build"),
)
def _validate_number(value: int, name: str) -> None:
if value < 0:
raise ValueError(f"{name} must not be a negative number")
def _validate_tuple(
identifiers: tuple[str, ...], name: str, forbid_numeric_leading_zero: bool
) -> None:
for identifier in identifiers:
if identifier == "":
raise ValueError(f"{name} may not have an empty identifier")
if re.match(f"^{_IDENTIFIER_REGEX}$", identifier) is None:
raise ValueError(f"{name} contains an identifier with invalid characters")
if (
forbid_numeric_leading_zero
and identifier.isdigit()
and identifier[0] == "0"
):
raise ValueError(f"{name} may not have a leading 0 for numeric identifier")
def _validate_number_group(match: re.Match[str], name: str) -> int:
# Just validate the requirements of string to int operation.
# _validate_number() is used for int specific validation.
value = match.group(name)
if value != "0" and value.startswith("0"):
raise ValueError(f"{name} may not have a leading zero")
return int(value)
def _group_to_tuple(match: re.Match[str], name: str) -> tuple[str, ...]:
value = match.group(name)
if value is None:
return ()
return tuple(value.split("."))