-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy path_specifier.py
More file actions
311 lines (262 loc) · 10.1 KB
/
_specifier.py
File metadata and controls
311 lines (262 loc) · 10.1 KB
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
"""Version specifier support using only standard library (PEP 440 compatible)."""
from __future__ import annotations
import contextlib
import operator
import re
import sys
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
_DC_KW = {"frozen": True, "kw_only": True, "slots": True} if sys.version_info >= (3, 10) else {"frozen": True}
if TYPE_CHECKING:
from collections.abc import Iterator
_VERSION_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(\d+) # major
(?:\.(\d+))? # optional minor
(?:\.(\d+))? # optional micro
(?:(a|b|rc)(\d+))? # optional pre-release suffix
$
""",
re.VERBOSE,
)
_SPECIFIER_RE: Final[re.Pattern[str]] = re.compile(
r"""
^
(===|==|~=|!=|<=|>=|<|>) # operator
\s*
(.+) # version string
$
""",
re.VERBOSE,
)
_PRE_ORDER: Final[dict[str, int]] = {"a": 1, "b": 2, "rc": 3}
@dataclass(**_DC_KW)
class SimpleVersion:
"""
Simple PEP 440-like version parser using only standard library.
:param version_str: the original version string.
:param major: major version number.
:param minor: minor version number.
:param micro: micro (patch) version number.
:param pre_type: pre-release label (``"a"``, ``"b"``, or ``"rc"``), or ``None``.
:param pre_num: pre-release sequence number, or ``None``.
:param release: the ``(major, minor, micro)`` tuple.
"""
version_str: str
major: int
minor: int
micro: int
pre_type: str | None
pre_num: int | None
release: tuple[int, int, int]
@classmethod
def from_string(cls, version_str: str) -> SimpleVersion:
"""
Parse a PEP 440 version string (e.g. ``3.12.1``).
:param version_str: the version string to parse.
"""
stripped = version_str.strip()
if not (match := _VERSION_RE.match(stripped)):
msg = f"Invalid version: {version_str}"
raise ValueError(msg)
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
micro = int(match.group(3)) if match.group(3) else 0
return cls(
version_str=stripped,
major=major,
minor=minor,
micro=micro,
pre_type=match.group(4),
pre_num=int(match.group(5)) if match.group(5) else None,
release=(major, minor, micro),
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleVersion):
return NotImplemented
return self.release == other.release and self.pre_type == other.pre_type and self.pre_num == other.pre_num
def __hash__(self) -> int:
return hash((self.release, self.pre_type, self.pre_num))
def __lt__(self, other: object) -> bool: # noqa: PLR0911
if not isinstance(other, SimpleVersion):
return NotImplemented
if self.release != other.release:
return self.release < other.release
if self.pre_type is None and other.pre_type is None:
return False
if self.pre_type is None:
return False
if other.pre_type is None:
return True
if _PRE_ORDER[self.pre_type] != _PRE_ORDER[other.pre_type]:
return _PRE_ORDER[self.pre_type] < _PRE_ORDER[other.pre_type]
return (self.pre_num or 0) < (other.pre_num or 0)
def __le__(self, other: object) -> bool:
return self == other or self < other
def __gt__(self, other: object) -> bool:
if not isinstance(other, SimpleVersion):
return NotImplemented
return not self <= other
def __ge__(self, other: object) -> bool:
return not self < other
def __str__(self) -> str:
return self.version_str
def __repr__(self) -> str:
return f"SimpleVersion('{self.version_str}')"
@dataclass(**_DC_KW)
class SimpleSpecifier:
"""
Simple PEP 440-like version specifier using only standard library.
:param spec_str: the original specifier string (e.g. ``>=3.10``).
:param operator: the comparison operator (``==``, ``>=``, ``<``, etc.).
:param version_str: the version portion of the specifier, without the operator.
:param is_wildcard: ``True`` if the specifier uses a wildcard suffix (``.*``).
:param wildcard_precision: number of version components before the wildcard, or ``None``.
:param version: the parsed version, or ``None`` if parsing failed.
"""
spec_str: str
operator: str
version_str: str
is_wildcard: bool
wildcard_precision: int | None
version: SimpleVersion | None
@classmethod
def from_string(cls, spec_str: str) -> SimpleSpecifier:
"""
Parse a single PEP 440 specifier (e.g. ``>=3.10``).
:param spec_str: the specifier string to parse.
"""
stripped = spec_str.strip()
if not (match := _SPECIFIER_RE.match(stripped)):
msg = f"Invalid specifier: {spec_str}"
raise ValueError(msg)
op = match.group(1)
version_str = match.group(2).strip()
is_wildcard = version_str.endswith(".*")
wildcard_precision: int | None = None
if is_wildcard:
version_str = version_str[:-2]
wildcard_precision = len(version_str.split("."))
try:
version = SimpleVersion.from_string(version_str)
except ValueError:
version = None
return cls(
spec_str=stripped,
operator=op,
version_str=version_str,
is_wildcard=is_wildcard,
wildcard_precision=wildcard_precision,
version=version,
)
def contains(self, version_str: str) -> bool:
"""
Check if a version string satisfies this specifier.
:param version_str: the version string to test.
"""
try:
candidate = SimpleVersion.from_string(version_str) if isinstance(version_str, str) else version_str
except ValueError:
return False
if self.version is None:
return False
if self.is_wildcard:
return self._check_wildcard(candidate)
return self._check_standard(candidate)
def _check_wildcard(self, candidate: SimpleVersion) -> bool:
if self.version is None: # pragma: no branch
return False # pragma: no cover
if self.operator == "==":
return candidate.release[: self.wildcard_precision] == self.version.release[: self.wildcard_precision]
if self.operator == "!=":
return candidate.release[: self.wildcard_precision] != self.version.release[: self.wildcard_precision]
return False
def _check_standard(self, candidate: SimpleVersion) -> bool:
if self.version is None: # pragma: no branch
return False # pragma: no cover
if self.operator == "===":
return str(candidate) == str(self.version)
if self.operator == "~=":
return self._check_compatible_release(candidate)
cmp_ops = {
"==": operator.eq,
"!=": operator.ne,
"<": operator.lt,
"<=": operator.le,
">": operator.gt,
">=": operator.ge,
}
if self.operator in cmp_ops:
return cmp_ops[self.operator](candidate, self.version)
return False
def _check_compatible_release(self, candidate: SimpleVersion) -> bool:
if self.version is None:
return False
if candidate < self.version:
return False
if len(self.version.release) >= 2: # noqa: PLR2004 # pragma: no branch # SimpleVersion always has 3-part release
upper_parts = list(self.version.release[:-1])
upper_parts[-1] += 1
upper = SimpleVersion.from_string(".".join(str(p) for p in upper_parts))
return candidate < upper
return True # pragma: no cover
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleSpecifier):
return NotImplemented
return self.spec_str == other.spec_str
def __hash__(self) -> int:
return hash(self.spec_str)
def __str__(self) -> str:
return self.spec_str
def __repr__(self) -> str:
return f"SimpleSpecifier('{self.spec_str}')"
@dataclass(**_DC_KW)
class SimpleSpecifierSet:
"""
Simple PEP 440-like specifier set using only standard library.
:param specifiers_str: the original comma-separated specifier string.
:param specifiers: the parsed individual specifiers.
"""
specifiers_str: str
specifiers: tuple[SimpleSpecifier, ...]
@classmethod
def from_string(cls, specifiers_str: str = "") -> SimpleSpecifierSet:
"""
Parse a comma-separated PEP 440 specifier string (e.g. ``>=3.10,<4``).
:param specifiers_str: the specifier string to parse.
"""
stripped = specifiers_str.strip()
specs: list[SimpleSpecifier] = []
if stripped:
for spec_item in stripped.split(","):
item = spec_item.strip()
if item:
with contextlib.suppress(ValueError):
specs.append(SimpleSpecifier.from_string(item))
return cls(specifiers_str=stripped, specifiers=tuple(specs))
def contains(self, version_str: str) -> bool:
"""
Check if a version satisfies all specifiers in the set.
:param version_str: the version string to test.
"""
if not self.specifiers:
return True
return all(spec.contains(version_str) for spec in self.specifiers)
def __iter__(self) -> Iterator[SimpleSpecifier]:
return iter(self.specifiers)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SimpleSpecifierSet):
return NotImplemented
return self.specifiers_str == other.specifiers_str
def __hash__(self) -> int:
return hash(self.specifiers_str)
def __str__(self) -> str:
return self.specifiers_str
def __repr__(self) -> str:
return f"SimpleSpecifierSet('{self.specifiers_str}')"
__all__ = [
"SimpleSpecifier",
"SimpleSpecifierSet",
"SimpleVersion",
]