Skip to content

Commit 4a615be

Browse files
authored
feat(bzlmod): support bazel downloader when downloading wheels (bazel-contrib#1827)
This introduces 3 attributes and the minimal code to be able to download wheels using the bazel downloader for the host platform. This is not yet adding support for targeting a different platform but just allows us to get the wheels for the host platform instead of using `pip`. All of this is achieved by calling the PyPI's SimpleAPI (Artifactory should work as well) and getting the all URLs for packages from there. Then we use the `sha256` information within the requirements files to match the entries found on SimpleAPI and then pass the `url`, `sha256` and the `filename` to `whl_library`, which uses `repository_ctx.download`. If we cannot find any suitable artifact to use, we fallback to legacy `pip` behaviour. Testing notes: * Most of the code has unit tests, but the `pypi_index.bzl` extension could have more. * You can see the lock file for what the output of all of this code would be on your platform. * Thanks to @dougthor42 for testing this using the credentials helper against a private registry that needs authentication to be accessed. Work towards bazel-contrib#1357
1 parent b9f39bf commit 4a615be

18 files changed

+1361
-77
lines changed

.bazelrc

+1-2
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,4 @@ build:rtd --stamp
3030
# Some bzl files contain repos only available under bzlmod
3131
build:rtd --enable_bzlmod
3232

33-
# Disabled due to https://github.com/bazelbuild/bazel/issues/20942
34-
build --lockfile_mode=off
33+
build --lockfile_mode=update

CHANGELOG.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ A brief description of the categories of changes:
2121

2222
### Changed
2323

24+
* (bzlmod): The `MODULE.bazel.lock` `whl_library` rule attributes are now
25+
sorted in the attributes section. We are also removing values that are not
26+
default in order to reduce the size of the lock file.
27+
* (deps): Bumped bazel_features to 1.9.1 to detect optional support
28+
non-blocking downloads.
29+
2430
### Fixed
2531

2632
* (whl_library): Fix the experimental_target_platforms overriding for platform
@@ -48,12 +54,15 @@ A brief description of the categories of changes:
4854
* (gazelle) Added a new `python_default_visibility` directive to control the
4955
_default_ visibility of generated targets. See the [docs][python_default_visibility]
5056
for details.
51-
5257
* (wheel) Add support for `data_files` attributes in py_wheel rule
5358
([#1777](https://github.com/bazelbuild/rules_python/issues/1777))
54-
5559
* (py_wheel) `bzlmod` installations now provide a `twine` setup for the default
5660
Python toolchain in `rules_python` for version 3.11.
61+
* (bzlmod) New `experimental_index_url`, `experimental_extra_index_urls` and
62+
`experimental_index_url_overrides` to `pip.parse` for using the bazel
63+
downloader. If you see any issues, report in
64+
[#1357](https://github.com/bazelbuild/rules_python/issues/1357). The URLs for
65+
the whl and sdist files will be written to the lock file.
5766

5867
[0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
5968
[python_default_visibility]: gazelle/README.md#directive-python_default_visibility

MODULE.bazel

+7-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module(
44
compatibility_level = 1,
55
)
66

7-
bazel_dep(name = "bazel_features", version = "1.1.1")
7+
bazel_dep(name = "bazel_features", version = "1.9.1")
88
bazel_dep(name = "bazel_skylib", version = "1.3.0")
99
bazel_dep(name = "platforms", version = "0.0.4")
1010

@@ -58,6 +58,7 @@ register_toolchains("@pythons_hub//:all")
5858

5959
pip = use_extension("//python/extensions:pip.bzl", "pip")
6060
pip.parse(
61+
experimental_index_url = "https://pypi.org/simple",
6162
hub_name = "rules_python_publish_deps",
6263
python_version = "3.11",
6364
requirements_darwin = "//tools/publish:requirements_darwin.txt",
@@ -69,7 +70,7 @@ use_repo(pip, "rules_python_publish_deps")
6970
# ===== DEV ONLY DEPS AND SETUP BELOW HERE =====
7071
bazel_dep(name = "stardoc", version = "0.6.2", dev_dependency = True, repo_name = "io_bazel_stardoc")
7172
bazel_dep(name = "rules_bazel_integration_test", version = "0.20.0", dev_dependency = True)
72-
bazel_dep(name = "rules_testing", version = "0.5.0", dev_dependency = True)
73+
bazel_dep(name = "rules_testing", version = "0.6.0", dev_dependency = True)
7374
bazel_dep(name = "rules_cc", version = "0.0.9", dev_dependency = True)
7475

7576
# Extra gazelle plugin deps so that WORKSPACE.bzlmod can continue including it for e2e tests.
@@ -83,6 +84,8 @@ dev_pip = use_extension(
8384
dev_dependency = True,
8485
)
8586
dev_pip.parse(
87+
envsubst = ["PIP_INDEX_URL"],
88+
experimental_index_url = "${PIP_INDEX_URL:-https://pypi.org/simple}",
8689
experimental_requirement_cycles = {
8790
"sphinx": [
8891
"sphinx",
@@ -98,6 +101,8 @@ dev_pip.parse(
98101
requirements_lock = "//docs/sphinx:requirements.txt",
99102
)
100103
dev_pip.parse(
104+
envsubst = ["PIP_INDEX_URL"],
105+
experimental_index_url = "${PIP_INDEX_URL:-https://pypi.org/simple}",
101106
hub_name = "pypiserver",
102107
python_version = "3.11",
103108
requirements_lock = "//examples/wheel:requirements_server.txt",

examples/bzlmod/MODULE.bazel

+14
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ use_repo(pip, "whl_mods_hub")
9494
# Alternatively, `python_interpreter_target` can be used to directly specify
9595
# the Python interpreter to run to resolve dependencies.
9696
pip.parse(
97+
# We can use `envsubst in the above
98+
envsubst = ["PIP_INDEX_URL"],
99+
# Use the bazel downloader to query the simple API for downloading the sources
100+
# Note, that we can use envsubst for this value.
101+
experimental_index_url = "${PIP_INDEX_URL:-https://pypi.org/simple}",
102+
# One can also select a particular index for a particular package.
103+
# This ensures that the setup is resistant against confusion attacks.
104+
# experimental_index_url_overrides = {
105+
# "my_package": "https://different-index-url.com",
106+
# },
107+
# Or you can specify extra indexes like with `pip`:
108+
# experimental_extra_index_urls = [
109+
# "https://different-index-url.com",
110+
# ],
97111
experimental_requirement_cycles = {
98112
"sphinx": [
99113
"sphinx",

internal_deps.bzl

+10-12
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,9 @@ def rules_python_internal_deps():
5757

5858
http_archive(
5959
name = "rules_testing",
60-
sha256 = "b84ed8546f1969d700ead4546de9f7637e0f058d835e47e865dcbb13c4210aed",
61-
strip_prefix = "rules_testing-0.5.0",
62-
url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.5.0/rules_testing-v0.5.0.tar.gz",
63-
)
64-
65-
http_archive(
66-
name = "rules_license",
67-
urls = [
68-
"https://mirror.bazel.build/github.com/bazelbuild/rules_license/releases/download/0.0.7/rules_license-0.0.7.tar.gz",
69-
"https://github.com/bazelbuild/rules_license/releases/download/0.0.7/rules_license-0.0.7.tar.gz",
70-
],
71-
sha256 = "4531deccb913639c30e5c7512a054d5d875698daeb75d8cf90f284375fe7c360",
60+
sha256 = "02c62574631876a4e3b02a1820cb51167bb9cdcdea2381b2fa9d9b8b11c407c4",
61+
strip_prefix = "rules_testing-0.6.0",
62+
url = "https://github.com/bazelbuild/rules_testing/releases/download/v0.6.0/rules_testing-v0.6.0.tar.gz",
7263
)
7364

7465
http_archive(
@@ -221,3 +212,10 @@ def rules_python_internal_deps():
221212
],
222213
sha256 = "4531deccb913639c30e5c7512a054d5d875698daeb75d8cf90f284375fe7c360",
223214
)
215+
216+
http_archive(
217+
name = "bazel_features",
218+
sha256 = "d7787da289a7fb497352211ad200ec9f698822a9e0757a4976fd9f713ff372b3",
219+
strip_prefix = "bazel_features-1.9.1",
220+
url = "https://github.com/bazel-contrib/bazel_features/releases/download/v1.9.1/bazel_features-v1.9.1.tar.gz",
221+
)

internal_setup.bzl

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
"""Setup for rules_python tests and tools."""
1616

17+
load("@bazel_features//:deps.bzl", "bazel_features_deps")
1718
load("@bazel_skylib//:workspace.bzl", "bazel_skylib_workspace")
1819
load("@cgrindel_bazel_starlib//:deps.bzl", "bazel_starlib_dependencies")
1920
load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
@@ -42,3 +43,4 @@ def rules_python_internal_setup():
4243
bazel_integration_test_rules_dependencies()
4344
bazel_starlib_dependencies()
4445
bazel_binaries(versions = SUPPORTED_BAZEL_VERSIONS)
46+
bazel_features_deps()

python/pip_install/pip_repository.bzl

+76-20
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse
2222
load("//python/pip_install/private:generate_group_library_build_bazel.bzl", "generate_group_library_build_bazel")
2323
load("//python/pip_install/private:generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel")
2424
load("//python/pip_install/private:srcs.bzl", "PIP_INSTALL_PY_SRCS")
25+
load("//python/private:auth.bzl", "AUTH_ATTRS", "get_auth")
2526
load("//python/private:envsubst.bzl", "envsubst")
2627
load("//python/private:normalize_name.bzl", "normalize_name")
2728
load("//python/private:parse_whl_name.bzl", "parse_whl_name")
@@ -187,7 +188,7 @@ def use_isolated(ctx, attr):
187188

188189
return use_isolated
189190

190-
def _parse_optional_attrs(rctx, args):
191+
def _parse_optional_attrs(rctx, args, extra_pip_args = None):
191192
"""Helper function to parse common attributes of pip_repository and whl_library repository rules.
192193
193194
This function also serializes the structured arguments as JSON
@@ -196,6 +197,7 @@ def _parse_optional_attrs(rctx, args):
196197
Args:
197198
rctx: Handle to the rule repository context.
198199
args: A list of parsed args for the rule.
200+
extra_pip_args: The pip args to pass.
199201
Returns: Augmented args list.
200202
"""
201203

@@ -212,7 +214,7 @@ def _parse_optional_attrs(rctx, args):
212214

213215
# Check for None so we use empty default types from our attrs.
214216
# Some args want to be list, and some want to be dict.
215-
if rctx.attr.extra_pip_args != None:
217+
if extra_pip_args != None:
216218
args += [
217219
"--extra_pip_args",
218220
json.encode(struct(arg = [
@@ -759,24 +761,64 @@ def _whl_library_impl(rctx):
759761
"--requirement",
760762
rctx.attr.requirement,
761763
]
762-
763-
args = _parse_optional_attrs(rctx, args)
764+
extra_pip_args = []
765+
extra_pip_args.extend(rctx.attr.extra_pip_args)
764766

765767
# Manually construct the PYTHONPATH since we cannot use the toolchain here
766768
environment = _create_repository_execution_environment(rctx, python_interpreter)
767769

768-
repo_utils.execute_checked(
769-
rctx,
770-
op = "whl_library.ResolveRequirement({}, {})".format(rctx.attr.name, rctx.attr.requirement),
771-
arguments = args,
772-
environment = environment,
773-
quiet = rctx.attr.quiet,
774-
timeout = rctx.attr.timeout,
775-
)
770+
whl_path = None
771+
if rctx.attr.whl_file:
772+
whl_path = rctx.path(rctx.attr.whl_file)
773+
774+
# Simulate the behaviour where the whl is present in the current directory.
775+
rctx.symlink(whl_path, whl_path.basename)
776+
whl_path = rctx.path(whl_path.basename)
777+
elif rctx.attr.urls:
778+
filename = rctx.attr.filename
779+
urls = rctx.attr.urls
780+
if not filename:
781+
_, _, filename = urls[0].rpartition("/")
782+
783+
if not (filename.endswith(".whl") or filename.endswith("tar.gz") or filename.endswith(".zip")):
784+
if rctx.attr.filename:
785+
msg = "got '{}'".format(filename)
786+
else:
787+
msg = "detected '{}' from url:\n{}".format(filename, urls[0])
788+
fail("Only '.whl', '.tar.gz' or '.zip' files are supported, {}".format(msg))
789+
790+
result = rctx.download(
791+
url = urls,
792+
output = filename,
793+
sha256 = rctx.attr.sha256,
794+
auth = get_auth(rctx, urls),
795+
)
796+
797+
if not result.success:
798+
fail("could not download the '{}' from {}:\n{}".format(filename, urls, result))
799+
800+
if filename.endswith(".whl"):
801+
whl_path = rctx.path(rctx.attr.filename)
802+
else:
803+
# It is an sdist and we need to tell PyPI to use a file in this directory
804+
# and not use any indexes.
805+
extra_pip_args.extend(["--no-index", "--find-links", "."])
806+
807+
args = _parse_optional_attrs(rctx, args, extra_pip_args)
776808

777-
whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
778-
if not rctx.delete("whl_file.json"):
779-
fail("failed to delete the whl_file.json file")
809+
if not whl_path:
810+
repo_utils.execute_checked(
811+
rctx,
812+
op = "whl_library.ResolveRequirement({}, {})".format(rctx.attr.name, rctx.attr.requirement),
813+
arguments = args,
814+
environment = environment,
815+
quiet = rctx.attr.quiet,
816+
timeout = rctx.attr.timeout,
817+
)
818+
819+
whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
820+
if not rctx.delete("whl_file.json"):
821+
fail("failed to delete the whl_file.json file")
780822

781823
if rctx.attr.whl_patches:
782824
patches = {}
@@ -890,14 +932,18 @@ if __name__ == "__main__":
890932
)
891933
return contents
892934

893-
whl_library_attrs = {
935+
# NOTE @aignas 2024-03-21: The usage of dict({}, **common) ensures that all args to `dict` are unique
936+
whl_library_attrs = dict({
894937
"annotation": attr.label(
895938
doc = (
896939
"Optional json encoded file containing annotation to apply to the extracted wheel. " +
897940
"See `package_annotation`"
898941
),
899942
allow_files = True,
900943
),
944+
"filename": attr.string(
945+
doc = "Download the whl file to this filename. Only used when the `urls` is passed. If not specified, will be auto-detected from the `urls`.",
946+
),
901947
"group_deps": attr.string_list(
902948
doc = "List of dependencies to skip in order to break the cycles within a dependency group.",
903949
default = [],
@@ -911,7 +957,18 @@ whl_library_attrs = {
911957
),
912958
"requirement": attr.string(
913959
mandatory = True,
914-
doc = "Python requirement string describing the package to make available",
960+
doc = "Python requirement string describing the package to make available, if 'urls' or 'whl_file' is given, then this only needs to include foo[any_extras] as a bare minimum.",
961+
),
962+
"sha256": attr.string(
963+
doc = "The sha256 of the downloaded whl. Only used when the `urls` is passed.",
964+
),
965+
"urls": attr.string_list(
966+
doc = """\
967+
The list of urls of the whl to be downloaded using bazel downloader. Using this
968+
attr makes `extra_pip_args` and `download_only` ignored.""",
969+
),
970+
"whl_file": attr.label(
971+
doc = "The whl file that should be used instead of downloading or building the whl.",
915972
),
916973
"whl_patches": attr.label_keyed_string_dict(
917974
doc = """a label-keyed-string dict that has
@@ -933,9 +990,8 @@ whl_library_attrs = {
933990
for repo in all_requirements
934991
],
935992
),
936-
}
937-
938-
whl_library_attrs.update(**common_attrs)
993+
}, **common_attrs)
994+
whl_library_attrs.update(AUTH_ATTRS)
939995

940996
whl_library = repository_rule(
941997
attrs = whl_library_attrs,

python/private/BUILD.bazel

+15
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,18 @@ bzl_library(
119119
srcs = ["parse_whl_name.bzl"],
120120
)
121121

122+
bzl_library(
123+
name = "pypi_index_bzl",
124+
srcs = ["pypi_index.bzl"],
125+
deps = [
126+
":auth_bzl",
127+
":normalize_name_bzl",
128+
":text_util_bzl",
129+
"//python/pip_install:requirements_parser_bzl",
130+
"//python/private/bzlmod:bazel_features_bzl",
131+
],
132+
)
133+
122134
bzl_library(
123135
name = "py_cc_toolchain_bzl",
124136
srcs = [
@@ -260,6 +272,9 @@ bzl_library(
260272
name = "whl_target_platforms_bzl",
261273
srcs = ["whl_target_platforms.bzl"],
262274
visibility = ["//:__subpackages__"],
275+
deps = [
276+
"parse_whl_name_bzl",
277+
],
263278
)
264279

265280
bzl_library(

0 commit comments

Comments
 (0)