Skip to content

Commit 247e065

Browse files
committed
feat(pypi): implement a new whl selection algorithm
DO NOT MERGE: stacked on bazel-contrib#3110 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 bazel-contrib#2747 Work towards bazel-contrib#2759 Work towards bazel-contrib#2849
1 parent 6b788ea commit 247e065

File tree

4 files changed

+706
-0
lines changed

4 files changed

+706
-0
lines changed

python/private/pypi/BUILD.bazel

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

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

python/private/pypi/select_whl.bzl

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