forked from tox-dev/python-discovery
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path_py_spec.py
More file actions
236 lines (209 loc) · 8.26 KB
/
_py_spec.py
File metadata and controls
236 lines (209 loc) · 8.26 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
"""A Python specification is an abstract requirement definition of an interpreter."""
from __future__ import annotations
import contextlib
import pathlib
import re
from typing import Final
from ._py_info import normalize_isa
from ._specifier import SimpleSpecifier, SimpleSpecifierSet, SimpleVersion
PATTERN = re.compile(
r"""
^
(?P<impl>[a-zA-Z]+)? # implementation (e.g. cpython, pypy)
(?P<version>[0-9.]+)? # version (e.g. 3.12, 3.12.1)
(?P<threaded>t)? # free-threaded flag
(?:-(?P<arch>32|64))? # architecture bitness
(?:-(?P<machine>[a-zA-Z0-9_]+))? # ISA (e.g. arm64, x86_64)
$
""",
re.VERBOSE,
)
SPECIFIER_PATTERN = re.compile(
r"""
^
(?:(?P<impl>[A-Za-z]+)\s*)? # optional implementation prefix
(?P<spec>(?:===|==|~=|!=|<=|>=|<|>).+) # PEP 440 version specifier
$
""",
re.VERBOSE,
)
_MAX_VERSION_PARTS: Final[int] = 3
_SINGLE_DIGIT_MAX: Final[int] = 9
SpecifierSet = SimpleSpecifierSet
Version = SimpleVersion
InvalidSpecifier = ValueError
InvalidVersion = ValueError
def _int_or_none(val: str | None) -> int | None:
return None if val is None else int(val)
def _parse_version_parts(version: str) -> tuple[int | None, int | None, int | None]:
versions = tuple(int(i) for i in version.split(".") if i)
if len(versions) > _MAX_VERSION_PARTS:
msg = "too many version parts"
raise ValueError(msg)
if len(versions) == _MAX_VERSION_PARTS:
return versions[0], versions[1], versions[2]
if len(versions) == 2: # noqa: PLR2004
return versions[0], versions[1], None
version_data = versions[0]
major = int(str(version_data)[0])
minor = int(str(version_data)[1:]) if version_data > _SINGLE_DIGIT_MAX else None
return major, minor, None
def _parse_spec_pattern(string_spec: str) -> PythonSpec | None:
if not (match := re.match(PATTERN, string_spec)):
return None
groups = match.groupdict()
version = groups["version"]
major, minor, micro, threaded = None, None, None, None
if version is not None:
try:
major, minor, micro = _parse_version_parts(version)
except ValueError:
return None
threaded = bool(groups["threaded"])
impl = groups["impl"]
if impl in {"py", "python"}:
impl = None
arch = _int_or_none(groups["arch"])
machine = groups.get("machine")
if machine is not None:
machine = normalize_isa(machine)
return PythonSpec(string_spec, impl, major, minor, micro, arch, None, free_threaded=threaded, machine=machine)
def _parse_specifier(string_spec: str) -> PythonSpec | None:
if not (specifier_match := SPECIFIER_PATTERN.match(string_spec.strip())):
return None
if SpecifierSet is None: # pragma: no cover
return None
impl = specifier_match.group("impl")
spec_text = specifier_match.group("spec").strip()
try:
version_specifier = SpecifierSet.from_string(spec_text)
except InvalidSpecifier: # pragma: no cover
return None
if impl in {"py", "python"}:
impl = None
return PythonSpec(string_spec, impl, None, None, None, None, None, version_specifier=version_specifier)
class PythonSpec:
"""Contains specification about a Python Interpreter."""
def __init__( # noqa: PLR0913, PLR0917
self,
str_spec: str,
implementation: str | None,
major: int | None,
minor: int | None,
micro: int | None,
architecture: int | None,
path: str | None,
*,
free_threaded: bool | None = None,
machine: str | None = None,
version_specifier: SpecifierSet | None = None,
) -> None:
self.str_spec = str_spec
self.implementation = implementation
self.major = major
self.minor = minor
self.micro = micro
self.free_threaded = free_threaded
self.architecture = architecture
self.machine = machine
self.path = path
self.version_specifier = version_specifier
@classmethod
def from_string_spec(cls, string_spec: str) -> PythonSpec:
"""Parse a string specification into a PythonSpec."""
if pathlib.Path(string_spec).is_absolute():
return cls(string_spec, None, None, None, None, None, string_spec)
if result := _parse_spec_pattern(string_spec):
return result
if result := _parse_specifier(string_spec):
return result
return cls(string_spec, None, None, None, None, None, string_spec)
def generate_re(self, *, windows: bool) -> re.Pattern:
"""Generate a regular expression for matching against a filename."""
version = r"{}(\.{}(\.{})?)?".format(
*(r"\d+" if v is None else v for v in (self.major, self.minor, self.micro)),
)
impl = "python" if self.implementation is None else f"python|{re.escape(self.implementation)}"
mod = "t?" if self.free_threaded else ""
suffix = r"\.exe" if windows else ""
version_conditional = "?" if windows or self.major is None else ""
return re.compile(
rf"(?P<impl>{impl})(?P<v>{version}{mod}){version_conditional}{suffix}$",
flags=re.IGNORECASE,
)
@property
def is_abs(self) -> bool:
"""``True`` if the spec refers to an absolute filesystem path."""
return self.path is not None and pathlib.Path(self.path).is_absolute()
def _check_version_specifier(self, spec: PythonSpec) -> bool:
"""Check if version specifier is satisfied."""
components: list[int] = []
for part in (self.major, self.minor, self.micro):
if part is None:
break
components.append(part)
if not components:
return True
version_str = ".".join(str(part) for part in components)
if spec.version_specifier is None:
return True
with contextlib.suppress(InvalidVersion):
Version.from_string(version_str)
for item in spec.version_specifier:
required_precision = self._get_required_precision(item)
if required_precision is None or len(components) < required_precision:
continue
if not item.contains(version_str):
return False
return True
@staticmethod
def _get_required_precision(item: SimpleSpecifier) -> int | None:
"""Get the required precision for a specifier item."""
if item.version is None:
return None
with contextlib.suppress(AttributeError, ValueError):
return len(item.version.release)
return None
def satisfies(self, spec: PythonSpec) -> bool: # noqa: PLR0911
"""Check if this spec is compatible with the given *spec* (e.g. PEP-514 on Windows)."""
if spec.is_abs and self.is_abs and self.path != spec.path:
return False
if (
spec.implementation is not None
and self.implementation is not None
and spec.implementation.lower() != self.implementation.lower()
):
return False
if spec.architecture is not None and spec.architecture != self.architecture:
return False
if spec.machine is not None and self.machine is not None and spec.machine != self.machine:
return False
if spec.free_threaded is not None and spec.free_threaded != self.free_threaded:
return False
if spec.version_specifier is not None and not self._check_version_specifier(spec):
return False
return all(
req is None or our is None or our == req
for our, req in zip((self.major, self.minor, self.micro), (spec.major, spec.minor, spec.micro))
)
def __repr__(self) -> str:
name = type(self).__name__
params = (
"implementation",
"major",
"minor",
"micro",
"architecture",
"machine",
"path",
"free_threaded",
"version_specifier",
)
return f"{name}({', '.join(f'{k}={getattr(self, k)}' for k in params if getattr(self, k) is not None)})"
__all__ = [
"InvalidSpecifier",
"InvalidVersion",
"PythonSpec",
"SpecifierSet",
"Version",
]