Skip to content

Commit 2b5447b

Browse files
aignasrickeylev
andauthored
feat(whl_library): generate platform-specific dependency closures (#1593)
Before this change, the dependency closures would be influenced by the host-python interpreter, this removes the influence by detecting the platforms against which the `Requires-Dist` wheel metadata is evaluated. This functionality can be enabled via `experimental_target_platforms` attribute to the `pip.parse` extension and is showcased in the `bzlmod` example. The same attribute is also supported on the legacy `pip_parse` repository rule. The detection works in the following way: - Check if the python wheel is platform specific or cross-platform (i.e., ends with `any.whl`), if it is then platform-specific dependencies are generated, which will go through a `select` statement. - If it is platform specific, then parse the platform_tag and evaluate the `Requires-Dist` markers assuming the target platform rather than the host platform. NOTE: The `whl` `METADATA` is now being parsed using the `packaging` Python package instead of `pkg_resources` from `setuptools`. Fixes #1591 --------- Co-authored-by: Richard Levasseur <[email protected]>
1 parent 6ffb04e commit 2b5447b

File tree

14 files changed

+938
-79
lines changed

14 files changed

+938
-79
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ A brief description of the categories of changes:
3636
* (gazelle) The gazelle plugin helper was not working with Python toolchains 3.11
3737
and above due to a bug in the helper components not being on PYTHONPATH.
3838

39+
* (pip_parse) The repositories created by `whl_library` can now parse the `whl`
40+
METADATA and generate dependency closures irrespective of the host platform
41+
the generation is executed on. This can be turned on by supplying
42+
`experimental_target_platforms = ["all"]` to the `pip_parse` or the `bzlmod`
43+
equivalent. This may help in cases where fetching wheels for a different
44+
platform using `download_only = True` feature.
45+
3946
[0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
4047

4148
## [0.27.0] - 2023-11-16

examples/bzlmod/MODULE.bazel

+14
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ pip.parse(
9898
"sphinxcontrib-serializinghtml",
9999
],
100100
},
101+
# You can use one of the values below to specify the target platform
102+
# to generate the dependency graph for.
103+
experimental_target_platforms = [
104+
"all",
105+
"linux_*",
106+
"host",
107+
],
101108
hub_name = "pip",
102109
python_version = "3.9",
103110
requirements_lock = "//:requirements_lock_3_9.txt",
@@ -121,6 +128,13 @@ pip.parse(
121128
"sphinxcontrib-serializinghtml",
122129
],
123130
},
131+
# You can use one of the values below to specify the target platform
132+
# to generate the dependency graph for.
133+
experimental_target_platforms = [
134+
"all",
135+
"linux_*",
136+
"host",
137+
],
124138
hub_name = "pip",
125139
python_version = "3.10",
126140
requirements_lock = "//:requirements_lock_3_10.txt",

python/pip_install/pip_repository.bzl

+32-2
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,8 @@ def _pip_repository_impl(rctx):
345345

346346
if rctx.attr.python_interpreter_target:
347347
config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target)
348+
if rctx.attr.experimental_target_platforms:
349+
config["experimental_target_platforms"] = rctx.attr.experimental_target_platforms
348350

349351
if rctx.attr.incompatible_generate_aliases:
350352
macro_tmpl = "@%s//{}:{}" % rctx.attr.name
@@ -472,6 +474,30 @@ Warning:
472474
If a dependency participates in multiple cycles, all of those cycles must be
473475
collapsed down to one. For instance `a <-> b` and `a <-> c` cannot be listed
474476
as two separate cycles.
477+
""",
478+
),
479+
"experimental_target_platforms": attr.string_list(
480+
default = [],
481+
doc = """\
482+
A list of platforms that we will generate the conditional dependency graph for
483+
cross platform wheels by parsing the wheel metadata. This will generate the
484+
correct dependencies for packages like `sphinx` or `pylint`, which include
485+
`colorama` when installed and used on Windows platforms.
486+
487+
An empty list means falling back to the legacy behaviour where the host
488+
platform is the target platform.
489+
490+
WARNING: It may not work as expected in cases where the python interpreter
491+
implementation that is being used at runtime is different between different platforms.
492+
This has been tested for CPython only.
493+
494+
Special values: `all` (for generating deps for all platforms), `host` (for
495+
generating deps for the host platform only). `linux_*` and other `<os>_*` values.
496+
In the future we plan to set `all` as the default to this attribute.
497+
498+
For specific target platforms use values of the form `<os>_<arch>` where `<os>`
499+
is one of `linux`, `osx`, `windows` and arch is one of `x86_64`, `x86_32`,
500+
`aarch64`, `s390x` and `ppc64le`.
475501
""",
476502
),
477503
"extra_pip_args": attr.string_list(
@@ -713,7 +739,10 @@ def _whl_library_impl(rctx):
713739
)
714740

715741
result = rctx.execute(
716-
args + ["--whl-file", whl_path],
742+
args + [
743+
"--whl-file",
744+
whl_path,
745+
] + ["--platform={}".format(p) for p in rctx.attr.experimental_target_platforms],
717746
environment = environment,
718747
quiet = rctx.attr.quiet,
719748
timeout = rctx.attr.timeout,
@@ -749,6 +778,7 @@ def _whl_library_impl(rctx):
749778
repo_prefix = rctx.attr.repo_prefix,
750779
whl_name = whl_path.basename,
751780
dependencies = metadata["deps"],
781+
dependencies_by_platform = metadata["deps_by_platform"],
752782
group_name = rctx.attr.group_name,
753783
group_deps = rctx.attr.group_deps,
754784
data_exclude = rctx.attr.pip_data_exclude,
@@ -815,7 +845,7 @@ whl_library_attrs = {
815845
doc = "Python requirement string describing the package to make available",
816846
),
817847
"whl_patches": attr.label_keyed_string_dict(
818-
doc = """"a label-keyed-string dict that has
848+
doc = """a label-keyed-string dict that has
819849
json.encode(struct([whl_file], patch_strip]) as values. This
820850
is to maintain flexibility and correct bzlmod extension interface
821851
until we have a better way to define whl_library and move whl

python/pip_install/private/generate_whl_library_build_bazel.bzl

+69-12
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ load(
2525
"WHEEL_FILE_PUBLIC_LABEL",
2626
)
2727
load("//python/private:normalize_name.bzl", "normalize_name")
28+
load("//python/private:text_util.bzl", "render")
2829

2930
_COPY_FILE_TEMPLATE = """\
3031
copy_file(
@@ -101,11 +102,36 @@ alias(
101102
)
102103
"""
103104

105+
def _render_list_and_select(deps, deps_by_platform, tmpl):
106+
deps = render.list([tmpl.format(d) for d in deps])
107+
108+
if not deps_by_platform:
109+
return deps
110+
111+
deps_by_platform = {
112+
p if p.startswith("@") else ":is_" + p: [
113+
tmpl.format(d)
114+
for d in deps
115+
]
116+
for p, deps in deps_by_platform.items()
117+
}
118+
119+
# Add the default, which means that we will be just using the dependencies in
120+
# `deps` for platforms that are not handled in a special way by the packages
121+
deps_by_platform["//conditions:default"] = []
122+
deps_by_platform = render.select(deps_by_platform, value_repr = render.list)
123+
124+
if deps == "[]":
125+
return deps_by_platform
126+
else:
127+
return "{} + {}".format(deps, deps_by_platform)
128+
104129
def generate_whl_library_build_bazel(
105130
*,
106131
repo_prefix,
107132
whl_name,
108133
dependencies,
134+
dependencies_by_platform,
109135
data_exclude,
110136
tags,
111137
entry_points,
@@ -118,6 +144,7 @@ def generate_whl_library_build_bazel(
118144
repo_prefix: the repo prefix that should be used for dependency lists.
119145
whl_name: the whl_name that this is generated for.
120146
dependencies: a list of PyPI packages that are dependencies to the py_library.
147+
dependencies_by_platform: a dict[str, list] of PyPI packages that may vary by platform.
121148
data_exclude: more patterns to exclude from the data attribute of generated py_library rules.
122149
tags: list of tags to apply to generated py_library rules.
123150
entry_points: A dict of entry points to add py_binary rules for.
@@ -138,6 +165,10 @@ def generate_whl_library_build_bazel(
138165
srcs_exclude = []
139166
data_exclude = [] + data_exclude
140167
dependencies = sorted([normalize_name(d) for d in dependencies])
168+
dependencies_by_platform = {
169+
platform: sorted([normalize_name(d) for d in deps])
170+
for platform, deps in dependencies_by_platform.items()
171+
}
141172
tags = sorted(tags)
142173

143174
for entry_point, entry_point_script_name in entry_points.items():
@@ -185,22 +216,48 @@ def generate_whl_library_build_bazel(
185216
for d in group_deps
186217
}
187218

188-
# Filter out deps which are within the group to avoid cycles
189-
non_group_deps = [
219+
dependencies = [
190220
d
191221
for d in dependencies
192222
if d not in group_deps
193223
]
224+
dependencies_by_platform = {
225+
p: deps
226+
for p, deps in dependencies_by_platform.items()
227+
for deps in [[d for d in deps if d not in group_deps]]
228+
if deps
229+
}
194230

195-
lib_dependencies = [
196-
"@%s%s//:%s" % (repo_prefix, normalize_name(d), PY_LIBRARY_PUBLIC_LABEL)
197-
for d in non_group_deps
198-
]
231+
for p in dependencies_by_platform:
232+
if p.startswith("@"):
233+
continue
199234

200-
whl_file_deps = [
201-
"@%s%s//:%s" % (repo_prefix, normalize_name(d), WHEEL_FILE_PUBLIC_LABEL)
202-
for d in non_group_deps
203-
]
235+
os, _, cpu = p.partition("_")
236+
237+
additional_content.append(
238+
"""\
239+
config_setting(
240+
name = "is_{os}_{cpu}",
241+
constraint_values = [
242+
"@platforms//cpu:{cpu}",
243+
"@platforms//os:{os}",
244+
],
245+
visibility = ["//visibility:private"],
246+
)
247+
""".format(os = os, cpu = cpu),
248+
)
249+
250+
lib_dependencies = _render_list_and_select(
251+
deps = dependencies,
252+
deps_by_platform = dependencies_by_platform,
253+
tmpl = "@{}{{}}//:{}".format(repo_prefix, PY_LIBRARY_PUBLIC_LABEL),
254+
)
255+
256+
whl_file_deps = _render_list_and_select(
257+
deps = dependencies,
258+
deps_by_platform = dependencies_by_platform,
259+
tmpl = "@{}{{}}//:{}".format(repo_prefix, WHEEL_FILE_PUBLIC_LABEL),
260+
)
204261

205262
# If this library is a member of a group, its public label aliases need to
206263
# point to the group implementation rule not the implementation rules. We
@@ -223,13 +280,13 @@ def generate_whl_library_build_bazel(
223280
py_library_public_label = PY_LIBRARY_PUBLIC_LABEL,
224281
py_library_impl_label = PY_LIBRARY_IMPL_LABEL,
225282
py_library_actual_label = library_impl_label,
226-
dependencies = repr(lib_dependencies),
283+
dependencies = render.indent(lib_dependencies, " " * 4).lstrip(),
284+
whl_file_deps = render.indent(whl_file_deps, " " * 4).lstrip(),
227285
data_exclude = repr(_data_exclude),
228286
whl_name = whl_name,
229287
whl_file_public_label = WHEEL_FILE_PUBLIC_LABEL,
230288
whl_file_impl_label = WHEEL_FILE_IMPL_LABEL,
231289
whl_file_actual_label = whl_impl_label,
232-
whl_file_deps = repr(whl_file_deps),
233290
tags = repr(tags),
234291
data_label = DATA_LABEL,
235292
dist_info_label = DIST_INFO_LABEL,

python/pip_install/tools/wheel_installer/BUILD.bazel

+13
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ py_library(
1313
deps = [
1414
requirement("installer"),
1515
requirement("pip"),
16+
requirement("packaging"),
1617
requirement("setuptools"),
1718
],
1819
)
@@ -47,6 +48,18 @@ py_test(
4748
],
4849
)
4950

51+
py_test(
52+
name = "wheel_test",
53+
size = "small",
54+
srcs = [
55+
"wheel_test.py",
56+
],
57+
data = ["//examples/wheel:minimal_with_py_package"],
58+
deps = [
59+
":lib",
60+
],
61+
)
62+
5063
py_test(
5164
name = "wheel_installer_test",
5265
size = "small",

python/pip_install/tools/wheel_installer/arguments.py

+26-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
import argparse
1616
import json
1717
import pathlib
18-
from typing import Any
18+
from typing import Any, Dict, Set
19+
20+
from python.pip_install.tools.wheel_installer import wheel
1921

2022

2123
def parser(**kwargs: Any) -> argparse.ArgumentParser:
@@ -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=wheel.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",
@@ -68,8 +76,9 @@ def parser(**kwargs: Any) -> argparse.ArgumentParser:
6876
return parser
6977

7078

71-
def deserialize_structured_args(args):
79+
def deserialize_structured_args(args: Dict[str, str]) -> Dict:
7280
"""Deserialize structured arguments passed from the starlark rules.
81+
7382
Args:
7483
args: dict of parsed command line arguments
7584
"""
@@ -80,3 +89,18 @@ def deserialize_structured_args(args):
8089
else:
8190
args[arg_name] = []
8291
return args
92+
93+
94+
def get_platforms(args: argparse.Namespace) -> Set:
95+
"""Aggregate platforms into a single set.
96+
97+
Args:
98+
args: dict of parsed command line arguments
99+
"""
100+
platforms = set()
101+
if args.platform is None:
102+
return platforms
103+
104+
platforms.update(args.platform)
105+
106+
return platforms

python/pip_install/tools/wheel_installer/arguments_test.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import json
1717
import unittest
1818

19-
from python.pip_install.tools.wheel_installer import arguments
19+
from python.pip_install.tools.wheel_installer import arguments, wheel
2020

2121

2222
class ArgumentsTestCase(unittest.TestCase):
@@ -52,6 +52,18 @@ def test_deserialize_structured_args(self) -> None:
5252
self.assertEqual(args["environment"], {"PIP_DO_SOMETHING": "True"})
5353
self.assertEqual(args["extra_pip_args"], [])
5454

55+
def test_platform_aggregation(self) -> None:
56+
parser = arguments.parser()
57+
args = parser.parse_args(
58+
args=[
59+
"--platform=host",
60+
"--platform=linux_*",
61+
"--platform=all",
62+
"--requirement=foo",
63+
]
64+
)
65+
self.assertEqual(set(wheel.Platform.all()), arguments.get_platforms(args))
66+
5567

5668
if __name__ == "__main__":
5769
unittest.main()

0 commit comments

Comments
 (0)