Skip to content

Commit 38b3dfe

Browse files
committed
feat(pip_parse): generate target platform specific dependencies
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 `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
1 parent 422f9df commit 38b3dfe

File tree

12 files changed

+822
-72
lines changed

12 files changed

+822
-72
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 done. This can be turned on by supplying
42+
`target_platforms = ["all"]` to the `pip_parse` or the `bzlmod` equivalent.
43+
This may help in cases where fetching wheels for a different platform using
44+
`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

+2
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ pip.parse(
102102
python_version = "3.9",
103103
requirements_lock = "//:requirements_lock_3_9.txt",
104104
requirements_windows = "//:requirements_windows_3_9.txt",
105+
target_platforms = ["all"],
105106
# These modifications were created above and we
106107
# are providing pip.parse with the label of the mod
107108
# and the name of the wheel.
@@ -125,6 +126,7 @@ pip.parse(
125126
python_version = "3.10",
126127
requirements_lock = "//:requirements_lock_3_10.txt",
127128
requirements_windows = "//:requirements_windows_3_10.txt",
129+
target_platforms = ["all"],
128130
# These modifications were created above and we
129131
# are providing pip.parse with the label of the mod
130132
# and the name of the wheel.

python/pip_install/pip_repository.bzl

+49-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools"
3535

3636
_WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point"
3737

38+
_ALL_PLATFORMS = [
39+
"linux_aarch64",
40+
"linux_ppc64le",
41+
"linux_s390x",
42+
"linux_x86_64",
43+
"linux_x86_32",
44+
"osx_aarch64",
45+
"osx_x86_64",
46+
"windows_x86_64",
47+
"windows_x86_32",
48+
"windows_aarch64",
49+
]
50+
3851
def _construct_pypath(rctx):
3952
"""Helper function to construct a PYTHONPATH.
4053
@@ -345,6 +358,8 @@ def _pip_repository_impl(rctx):
345358

346359
if rctx.attr.python_interpreter_target:
347360
config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target)
361+
if rctx.attr.target_platforms:
362+
config["target_platforms"] = rctx.attr.target_platforms
348363

349364
if rctx.attr.incompatible_generate_aliases:
350365
macro_tmpl = "@%s//{}:{}" % rctx.attr.name
@@ -513,6 +528,25 @@ python_interpreter. An example value: "@python3_x86_64-unknown-linux-gnu//:pytho
513528
"repo_prefix": attr.string(
514529
doc = """
515530
Prefix for the generated packages will be of the form `@<prefix><sanitized-package-name>//...`
531+
""",
532+
),
533+
"target_platforms": attr.string_list(
534+
# TODO @aignas 2023-12-04: this is not the best way right now
535+
# so if this approach is generally good, this can be refactored, by
536+
# using the names of the config_setting labels.
537+
default = [],
538+
doc = """\
539+
A list of platforms that we will generate the conditional dependency graph for
540+
cross platform wheels by parsing the wheel metadata. This will generate the
541+
correct dependencies for packages like `sphinx` or `pylint`, which include
542+
`colorama` when installed and used on Windows platforms.
543+
544+
An empty list means falling back to the legacy behaviour when we use the host
545+
platform is the target platform.
546+
547+
WARNING: It may not work as expected in cases where the python interpreter
548+
implementation that is being used at runtime is different between different platforms.
549+
This has been tested for CPython only.
516550
""",
517551
),
518552
# 600 is documented as default here: https://docs.bazel.build/versions/master/skylark/lib/repository_ctx.html#execute
@@ -669,6 +703,15 @@ See the example in rules_python/examples/pip_parse_vendored.
669703
)
670704

671705
def _whl_library_impl(rctx):
706+
target_platforms = []
707+
if len(rctx.attr.target_platforms) == 1 and rctx.attr.target_platforms[0] == "all":
708+
target_platforms = _ALL_PLATFORMS
709+
elif rctx.attr.target_platforms:
710+
target_platforms = rctx.attr.target_platforms
711+
for p in target_platforms:
712+
if p not in _ALL_PLATFORMS:
713+
fail("target_platforms must be a subset of {} but got {}".format(["all"] + _ALL_PLATFORMS, target_platforms))
714+
672715
python_interpreter = _resolve_python_interpreter(rctx)
673716
args = [
674717
python_interpreter,
@@ -713,7 +756,10 @@ def _whl_library_impl(rctx):
713756
)
714757

715758
result = rctx.execute(
716-
args + ["--whl-file", whl_path],
759+
args + [
760+
"--whl-file",
761+
whl_path,
762+
] + ["--platform={}".format(p) for p in target_platforms],
717763
environment = environment,
718764
quiet = rctx.attr.quiet,
719765
timeout = rctx.attr.timeout,
@@ -749,6 +795,7 @@ def _whl_library_impl(rctx):
749795
repo_prefix = rctx.attr.repo_prefix,
750796
whl_name = whl_path.basename,
751797
dependencies = metadata["deps"],
798+
dependencies_by_platform = metadata["deps_by_platform"],
752799
group_name = rctx.attr.group_name,
753800
group_deps = rctx.attr.group_deps,
754801
data_exclude = rctx.attr.pip_data_exclude,
@@ -815,7 +862,7 @@ whl_library_attrs = {
815862
doc = "Python requirement string describing the package to make available",
816863
),
817864
"whl_patches": attr.label_keyed_string_dict(
818-
doc = """"a label-keyed-string dict that has
865+
doc = """a label-keyed-string dict that has
819866
json.encode(struct([whl_file], patch_strip]) as values. This
820867
is to maintain flexibility and correct bzlmod extension interface
821868
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

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

20+
from python.pip_install.tools.wheel_installer import wheel
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="append",
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",

0 commit comments

Comments
 (0)