Skip to content

Commit 3262233

Browse files
authored
feat(pypi): implement a new whl selection algorithm (#3111)
This PR only implements the selection algorithm where instead of selecting all wheels that are compatible with the set of target platforms, we select a single wheel that is most specialized for a particular single target platform. What is more, compared to the existing algorithm it does not assume a particular list of supported platforms and just fully implements the spec. Work towards #2747 Work towards #2759 Work towards #2849
1 parent bba0759 commit 3262233

File tree

4 files changed

+713
-0
lines changed

4 files changed

+713
-0
lines changed

python/private/pypi/BUILD.bazel

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,16 @@ bzl_library(
365365
],
366366
)
367367

368+
bzl_library(
369+
name = "select_whl_bzl",
370+
srcs = ["select_whl.bzl"],
371+
deps = [
372+
":parse_whl_name_bzl",
373+
":python_tag_bzl",
374+
"//python/private:version_bzl",
375+
],
376+
)
377+
368378
bzl_library(
369379
name = "simpleapi_download_bzl",
370380
srcs = ["simpleapi_download.bzl"],

python/private/pypi/select_whl.bzl

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"Select a single wheel that fits the parameters of a target platform."
2+
3+
load("//python/private:version.bzl", "version")
4+
load(":parse_whl_name.bzl", "parse_whl_name")
5+
load(":python_tag.bzl", "PY_TAG_GENERIC", "python_tag")
6+
7+
_ANDROID = "android"
8+
_IOS = "ios"
9+
_MANYLINUX = "manylinux"
10+
_MACOSX = "macosx"
11+
_MUSLLINUX = "musllinux"
12+
13+
def _value_priority(*, tag, values):
14+
keys = []
15+
for priority, wp in enumerate(values):
16+
if tag == wp:
17+
keys.append(priority)
18+
19+
return max(keys) if keys else None
20+
21+
def _platform_tag_priority(*, tag, values):
22+
# Implements matching platform tag
23+
# https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/
24+
25+
if not (
26+
tag.startswith(_ANDROID) or
27+
tag.startswith(_IOS) or
28+
tag.startswith(_MACOSX) or
29+
tag.startswith(_MANYLINUX) or
30+
tag.startswith(_MUSLLINUX)
31+
):
32+
res = _value_priority(tag = tag, values = values)
33+
if res == None:
34+
return res
35+
36+
return (res, (0, 0))
37+
38+
# Only android, ios, macosx, manylinux or musllinux platforms should be considered
39+
40+
os, _, tail = tag.partition("_")
41+
major, _, tail = tail.partition("_")
42+
if not os.startswith(_ANDROID):
43+
minor, _, arch = tail.partition("_")
44+
else:
45+
minor = "0"
46+
arch = tail
47+
version = (int(major), int(minor))
48+
49+
keys = []
50+
for priority, wp in enumerate(values):
51+
want_os, sep, tail = wp.partition("_")
52+
if not sep:
53+
# if there is no `_` separator, then it means that we have something like `win32` or
54+
# similar wheels that we are considering, this means that it should be discarded because
55+
# we are dealing only with platforms that have `_`.
56+
continue
57+
58+
if want_os != os:
59+
# os should always match exactly for us to match and assign a priority
60+
continue
61+
62+
want_major, _, tail = tail.partition("_")
63+
if want_major == "*":
64+
# the expected match is any version
65+
want_major = ""
66+
want_minor = ""
67+
want_arch = tail
68+
elif os.startswith(_ANDROID):
69+
# we set it to `0` above, so setting the `want_minor` her to `0` will make things
70+
# consistent.
71+
want_minor = "0"
72+
want_arch = tail
73+
else:
74+
# here we parse the values from the given platform
75+
want_minor, _, want_arch = tail.partition("_")
76+
77+
if want_arch != arch:
78+
# the arch should match exactly
79+
continue
80+
81+
# if want_major is defined, then we know that we don't have a `*` in the matcher.
82+
want_version = (int(want_major), int(want_minor)) if want_major else None
83+
if not want_version or version <= want_version:
84+
keys.append((priority, version))
85+
86+
return max(keys) if keys else None
87+
88+
def _python_tag_priority(*, tag, implementation, py_version):
89+
if tag.startswith(PY_TAG_GENERIC):
90+
ver_str = tag[len(PY_TAG_GENERIC):]
91+
elif tag.startswith(implementation):
92+
ver_str = tag[len(implementation):]
93+
else:
94+
return None
95+
96+
# Add a 0 at the end in case it is a single digit
97+
ver_str = "{}.{}".format(ver_str[0], ver_str[1:] or "0")
98+
99+
ver = version.parse(ver_str)
100+
if not version.is_compatible(py_version, ver):
101+
return None
102+
103+
return (
104+
tag.startswith(implementation),
105+
version.key(ver),
106+
)
107+
108+
def _candidates_by_priority(
109+
*,
110+
whls,
111+
implementation_name,
112+
python_version,
113+
whl_abi_tags,
114+
whl_platform_tags,
115+
logger):
116+
"""Calculate the priority of each wheel
117+
118+
Returns:
119+
A dictionary where keys are priority tuples which allows us to sort and pick the
120+
last item.
121+
"""
122+
py_version = version.parse(python_version, strict = True)
123+
implementation = python_tag(implementation_name)
124+
125+
ret = {}
126+
for whl in whls:
127+
parsed = parse_whl_name(whl.filename)
128+
priority = None
129+
130+
# See https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/#compressed-tag-sets
131+
for platform in parsed.platform_tag.split("."):
132+
platform = _platform_tag_priority(tag = platform, values = whl_platform_tags)
133+
if platform == None:
134+
logger.debug(lambda: "The platform_tag in '{}' does not match given list: {}".format(
135+
whl.filename,
136+
whl_platform_tags,
137+
))
138+
continue
139+
140+
for py in parsed.python_tag.split("."):
141+
py = _python_tag_priority(
142+
tag = py,
143+
implementation = implementation,
144+
py_version = py_version,
145+
)
146+
if py == None:
147+
logger.debug(lambda: "The python_tag in '{}' does not match implementation or version: {} {}".format(
148+
whl.filename,
149+
implementation,
150+
py_version.string,
151+
))
152+
continue
153+
154+
for abi in parsed.abi_tag.split("."):
155+
abi = _value_priority(
156+
tag = abi,
157+
values = whl_abi_tags,
158+
)
159+
if abi == None:
160+
logger.debug(lambda: "The abi_tag in '{}' does not match given list: {}".format(
161+
whl.filename,
162+
whl_abi_tags,
163+
))
164+
continue
165+
166+
# 1. Prefer platform wheels
167+
# 2. Then prefer implementation/python version
168+
# 3. Then prefer more specific ABI wheels
169+
candidate = (platform, py, abi)
170+
priority = priority or candidate
171+
if candidate > priority:
172+
priority = candidate
173+
174+
if priority == None:
175+
logger.debug(lambda: "The whl '{}' is incompatible".format(
176+
whl.filename,
177+
))
178+
continue
179+
180+
ret[priority] = whl
181+
182+
return ret
183+
184+
def select_whl(
185+
*,
186+
whls,
187+
python_version,
188+
whl_platform_tags,
189+
whl_abi_tags,
190+
implementation_name = "cpython",
191+
limit = 1,
192+
logger):
193+
"""Select a whl that is the most suitable for the given platform.
194+
195+
Args:
196+
whls: {type}`list[struct]` a list of candidates which have a `filename`
197+
attribute containing the `whl` filename.
198+
python_version: {type}`str` the target python version.
199+
implementation_name: {type}`str` the `implementation_name` from the target_platform env.
200+
whl_abi_tags: {type}`list[str]` The whl abi tags to select from. The preference is
201+
for wheels that have ABI values appearing later in the `whl_abi_tags` list.
202+
whl_platform_tags: {type}`list[str]` The whl platform tags to select from.
203+
The platform tag may contain `*` and this means that if the platform tag is
204+
versioned (e.g. `manylinux`), then we will select the highest available
205+
platform version, e.g. if `manylinux_2_17` and `manylinux_2_5` wheels are both
206+
compatible, we will select `manylinux_2_17`. Otherwise for versioned platform
207+
tags we select the highest *compatible* version, e.g. if `manylinux_2_6`
208+
support is requested, then we would select `manylinux_2_5` in the previous
209+
example. This allows us to pass the same filtering parameters when selecting
210+
all of the whl dependencies irrespective of what actual platform tags they
211+
contain.
212+
limit: {type}`int` number of wheels to return. Defaults to 1.
213+
logger: {type}`struct` the logger instance.
214+
215+
Returns:
216+
{type}`list[struct] | struct | None`, a single struct from the `whls` input
217+
argument or `None` if a match is not found. If the `limit` is greater than
218+
one, then we will return a list.
219+
"""
220+
candidates = _candidates_by_priority(
221+
whls = whls,
222+
implementation_name = implementation_name,
223+
python_version = python_version,
224+
whl_abi_tags = whl_abi_tags,
225+
whl_platform_tags = whl_platform_tags,
226+
logger = logger,
227+
)
228+
229+
if not candidates:
230+
return None
231+
232+
res = [i[1] for i in sorted(candidates.items())]
233+
logger.debug(lambda: "Sorted candidates:\n{}".format(
234+
"\n".join([c.filename for c in res]),
235+
))
236+
237+
return res[-1] if limit == 1 else res[-limit:]

tests/pypi/select_whl/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
load(":select_whl_tests.bzl", "select_whl_test_suite")
2+
3+
select_whl_test_suite(name = "select_whl_tests")

0 commit comments

Comments
 (0)