Skip to content

Commit 61c91fe

Browse files
authored
revert(pypi): bring back Python PEP508 code with tests (#2831)
This just adds the code back at the original state before the following PRs have been made to remove them: #2629, #2781. This has not been hooked up yet in `evaluate_markers` and `whl_library` yet and I'll need extra PRs to do that. No CHANGELOG entries for now, will be done once the integration is back. Work towards #2830
1 parent 7234dda commit 61c91fe

File tree

12 files changed

+1285
-14
lines changed

12 files changed

+1285
-14
lines changed

python/private/pypi/requirements_parser/BUILD.bazel

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""A CLI to evaluate env markers for requirements files.
2+
3+
A simple script to evaluate the `requirements.txt` files. Currently it is only
4+
handling environment markers in the requirements files, but in the future it
5+
may handle more things. We require a `python` interpreter that can run on the
6+
host platform and then we depend on the [packaging] PyPI wheel.
7+
8+
In order to be able to resolve requirements files for any platform, we are
9+
re-using the same code that is used in the `whl_library` installer. See
10+
[here](../whl_installer/wheel.py).
11+
12+
Requirements for the code are:
13+
- Depends only on `packaging` and core Python.
14+
- Produces the same result irrespective of the Python interpreter platform or version.
15+
16+
[packaging]: https://packaging.pypa.io/en/stable/
17+
"""
18+
19+
import argparse
20+
import json
21+
import pathlib
22+
23+
from packaging.requirements import Requirement
24+
25+
from python.private.pypi.whl_installer.platform import Platform
26+
27+
INPUT_HELP = """\
28+
Input path to read the requirements as a json file, the keys in the dictionary
29+
are the requirements lines and the values are strings of target platforms.
30+
"""
31+
OUTPUT_HELP = """\
32+
Output to write the requirements as a json filepath, the keys in the dictionary
33+
are the requirements lines and the values are strings of target platforms, which
34+
got changed based on the evaluated markers.
35+
"""
36+
37+
38+
def main():
39+
parser = argparse.ArgumentParser(description=__doc__)
40+
parser.add_argument("input_path", type=pathlib.Path, help=INPUT_HELP.strip())
41+
parser.add_argument("output_path", type=pathlib.Path, help=OUTPUT_HELP.strip())
42+
args = parser.parse_args()
43+
44+
with args.input_path.open() as f:
45+
reqs = json.load(f)
46+
47+
response = {}
48+
for requirement_line, target_platforms in reqs.items():
49+
entry, prefix, hashes = requirement_line.partition("--hash")
50+
hashes = prefix + hashes
51+
52+
req = Requirement(entry)
53+
for p in target_platforms:
54+
(platform,) = Platform.from_string(p)
55+
if not req.marker or req.marker.evaluate(platform.env_markers("")):
56+
response.setdefault(requirement_line, []).append(p)
57+
58+
with args.output_path.open("w") as f:
59+
json.dump(response, f)
60+
61+
62+
if __name__ == "__main__":
63+
main()

python/private/pypi/whl_installer/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ py_library(
66
srcs = [
77
"arguments.py",
88
"namespace_pkgs.py",
9+
"platform.py",
910
"wheel.py",
1011
"wheel_installer.py",
1112
],

python/private/pypi/whl_installer/arguments.py

+8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import pathlib
1818
from typing import Any, Dict, Set
1919

20+
from python.private.pypi.whl_installer.platform import Platform
21+
2022

2123
def parser(**kwargs: Any) -> argparse.ArgumentParser:
2224
"""Create a parser for the wheel_installer tool."""
@@ -39,6 +41,12 @@ def parser(**kwargs: Any) -> argparse.ArgumentParser:
3941
action="store",
4042
help="Extra arguments to pass down to pip.",
4143
)
44+
parser.add_argument(
45+
"--platform",
46+
action="extend",
47+
type=Platform.from_string,
48+
help="Platforms to target dependencies. Can be used multiple times.",
49+
)
4250
parser.add_argument(
4351
"--pip_data_exclude",
4452
action="store",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
# Copyright 2024 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Utility class to inspect an extracted wheel directory"""
16+
17+
import platform
18+
import sys
19+
from dataclasses import dataclass
20+
from enum import Enum
21+
from typing import Any, Dict, Iterator, List, Optional, Union
22+
23+
24+
class OS(Enum):
25+
linux = 1
26+
osx = 2
27+
windows = 3
28+
darwin = osx
29+
win32 = windows
30+
31+
@classmethod
32+
def interpreter(cls) -> "OS":
33+
"Return the interpreter operating system."
34+
return cls[sys.platform.lower()]
35+
36+
def __str__(self) -> str:
37+
return self.name.lower()
38+
39+
40+
class Arch(Enum):
41+
x86_64 = 1
42+
x86_32 = 2
43+
aarch64 = 3
44+
ppc = 4
45+
ppc64le = 5
46+
s390x = 6
47+
arm = 7
48+
amd64 = x86_64
49+
arm64 = aarch64
50+
i386 = x86_32
51+
i686 = x86_32
52+
x86 = x86_32
53+
54+
@classmethod
55+
def interpreter(cls) -> "Arch":
56+
"Return the currently running interpreter architecture."
57+
# FIXME @aignas 2023-12-13: Hermetic toolchain on Windows 3.11.6
58+
# is returning an empty string here, so lets default to x86_64
59+
return cls[platform.machine().lower() or "x86_64"]
60+
61+
def __str__(self) -> str:
62+
return self.name.lower()
63+
64+
65+
def _as_int(value: Optional[Union[OS, Arch]]) -> int:
66+
"""Convert one of the enums above to an int for easier sorting algorithms.
67+
68+
Args:
69+
value: The value of an enum or None.
70+
71+
Returns:
72+
-1 if we get None, otherwise, the numeric value of the given enum.
73+
"""
74+
if value is None:
75+
return -1
76+
77+
return int(value.value)
78+
79+
80+
def host_interpreter_minor_version() -> int:
81+
return sys.version_info.minor
82+
83+
84+
@dataclass(frozen=True)
85+
class Platform:
86+
os: Optional[OS] = None
87+
arch: Optional[Arch] = None
88+
minor_version: Optional[int] = None
89+
90+
@classmethod
91+
def all(
92+
cls,
93+
want_os: Optional[OS] = None,
94+
minor_version: Optional[int] = None,
95+
) -> List["Platform"]:
96+
return sorted(
97+
[
98+
cls(os=os, arch=arch, minor_version=minor_version)
99+
for os in OS
100+
for arch in Arch
101+
if not want_os or want_os == os
102+
]
103+
)
104+
105+
@classmethod
106+
def host(cls) -> List["Platform"]:
107+
"""Use the Python interpreter to detect the platform.
108+
109+
We extract `os` from sys.platform and `arch` from platform.machine
110+
111+
Returns:
112+
A list of parsed values which makes the signature the same as
113+
`Platform.all` and `Platform.from_string`.
114+
"""
115+
return [
116+
Platform(
117+
os=OS.interpreter(),
118+
arch=Arch.interpreter(),
119+
minor_version=host_interpreter_minor_version(),
120+
)
121+
]
122+
123+
def all_specializations(self) -> Iterator["Platform"]:
124+
"""Return the platform itself and all its unambiguous specializations.
125+
126+
For more info about specializations see
127+
https://bazel.build/docs/configurable-attributes
128+
"""
129+
yield self
130+
if self.arch is None:
131+
for arch in Arch:
132+
yield Platform(os=self.os, arch=arch, minor_version=self.minor_version)
133+
if self.os is None:
134+
for os in OS:
135+
yield Platform(os=os, arch=self.arch, minor_version=self.minor_version)
136+
if self.arch is None and self.os is None:
137+
for os in OS:
138+
for arch in Arch:
139+
yield Platform(os=os, arch=arch, minor_version=self.minor_version)
140+
141+
def __lt__(self, other: Any) -> bool:
142+
"""Add a comparison method, so that `sorted` returns the most specialized platforms first."""
143+
if not isinstance(other, Platform) or other is None:
144+
raise ValueError(f"cannot compare {other} with Platform")
145+
146+
self_arch, self_os = _as_int(self.arch), _as_int(self.os)
147+
other_arch, other_os = _as_int(other.arch), _as_int(other.os)
148+
149+
if self_os == other_os:
150+
return self_arch < other_arch
151+
else:
152+
return self_os < other_os
153+
154+
def __str__(self) -> str:
155+
if self.minor_version is None:
156+
if self.os is None and self.arch is None:
157+
return "//conditions:default"
158+
159+
if self.arch is None:
160+
return f"@platforms//os:{self.os}"
161+
else:
162+
return f"{self.os}_{self.arch}"
163+
164+
if self.arch is None and self.os is None:
165+
return f"@//python/config_settings:is_python_3.{self.minor_version}"
166+
167+
if self.arch is None:
168+
return f"cp3{self.minor_version}_{self.os}_anyarch"
169+
170+
if self.os is None:
171+
return f"cp3{self.minor_version}_anyos_{self.arch}"
172+
173+
return f"cp3{self.minor_version}_{self.os}_{self.arch}"
174+
175+
@classmethod
176+
def from_string(cls, platform: Union[str, List[str]]) -> List["Platform"]:
177+
"""Parse a string and return a list of platforms"""
178+
platform = [platform] if isinstance(platform, str) else list(platform)
179+
ret = set()
180+
for p in platform:
181+
if p == "host":
182+
ret.update(cls.host())
183+
continue
184+
185+
abi, _, tail = p.partition("_")
186+
if not abi.startswith("cp"):
187+
# The first item is not an abi
188+
tail = p
189+
abi = ""
190+
os, _, arch = tail.partition("_")
191+
arch = arch or "*"
192+
193+
minor_version = int(abi[len("cp3") :]) if abi else None
194+
195+
if arch != "*":
196+
ret.add(
197+
cls(
198+
os=OS[os] if os != "*" else None,
199+
arch=Arch[arch],
200+
minor_version=minor_version,
201+
)
202+
)
203+
204+
else:
205+
ret.update(
206+
cls.all(
207+
want_os=OS[os] if os != "*" else None,
208+
minor_version=minor_version,
209+
)
210+
)
211+
212+
return sorted(ret)
213+
214+
# NOTE @aignas 2023-12-05: below is the minimum number of accessors that are defined in
215+
# https://peps.python.org/pep-0496/ to make rules_python generate dependencies.
216+
#
217+
# WARNING: It may not work in cases where the python implementation is different between
218+
# different platforms.
219+
220+
# derived from OS
221+
@property
222+
def os_name(self) -> str:
223+
if self.os == OS.linux or self.os == OS.osx:
224+
return "posix"
225+
elif self.os == OS.windows:
226+
return "nt"
227+
else:
228+
return ""
229+
230+
@property
231+
def sys_platform(self) -> str:
232+
if self.os == OS.linux:
233+
return "linux"
234+
elif self.os == OS.osx:
235+
return "darwin"
236+
elif self.os == OS.windows:
237+
return "win32"
238+
else:
239+
return ""
240+
241+
@property
242+
def platform_system(self) -> str:
243+
if self.os == OS.linux:
244+
return "Linux"
245+
elif self.os == OS.osx:
246+
return "Darwin"
247+
elif self.os == OS.windows:
248+
return "Windows"
249+
else:
250+
return ""
251+
252+
# derived from OS and Arch
253+
@property
254+
def platform_machine(self) -> str:
255+
"""Guess the target 'platform_machine' marker.
256+
257+
NOTE @aignas 2023-12-05: this may not work on really new systems, like
258+
Windows if they define the platform markers in a different way.
259+
"""
260+
if self.arch == Arch.x86_64:
261+
return "x86_64"
262+
elif self.arch == Arch.x86_32 and self.os != OS.osx:
263+
return "i386"
264+
elif self.arch == Arch.x86_32:
265+
return ""
266+
elif self.arch == Arch.aarch64 and self.os == OS.linux:
267+
return "aarch64"
268+
elif self.arch == Arch.aarch64:
269+
# Assuming that OSX and Windows use this one since the precedent is set here:
270+
# https://github.com/cgohlke/win_arm64-wheels
271+
return "arm64"
272+
elif self.os != OS.linux:
273+
return ""
274+
elif self.arch == Arch.ppc:
275+
return "ppc"
276+
elif self.arch == Arch.ppc64le:
277+
return "ppc64le"
278+
elif self.arch == Arch.s390x:
279+
return "s390x"
280+
else:
281+
return ""
282+
283+
def env_markers(self, extra: str) -> Dict[str, str]:
284+
# If it is None, use the host version
285+
minor_version = self.minor_version or host_interpreter_minor_version()
286+
287+
return {
288+
"extra": extra,
289+
"os_name": self.os_name,
290+
"sys_platform": self.sys_platform,
291+
"platform_machine": self.platform_machine,
292+
"platform_system": self.platform_system,
293+
"platform_release": "", # unset
294+
"platform_version": "", # unset
295+
"python_version": f"3.{minor_version}",
296+
# FIXME @aignas 2024-01-14: is putting zero last a good idea? Maybe we should
297+
# use `20` or something else to avoid having weird issues where the full version is used for
298+
# matching and the author decides to only support 3.y.5 upwards.
299+
"implementation_version": f"3.{minor_version}.0",
300+
"python_full_version": f"3.{minor_version}.0",
301+
# we assume that the following are the same as the interpreter used to setup the deps:
302+
# "implementation_name": "cpython"
303+
# "platform_python_implementation: "CPython",
304+
}

0 commit comments

Comments
 (0)