Skip to content

Commit 6720945

Browse files
committed
feat(bzlmod): introduce pypi_index for using bazel's downloader
This is a variant of bazel-contrib#1625 and was inspired by bazel-contrib#1788. In bazel-contrib#1625, we attempt to parse the simple API HTML files in the same `pip.parse` extension and it brings the follownig challenges: * The `pip.parse` cannot be easily use in `isolated` mode and it may be difficult to implement the isolation if bazelbuild/bazel#20186 moves forward. * Splitting the `pypi_index` out of the `pip.parse` allows us to accept the location of the parsed simple API artifacts encoded as a bazel label. * Separation of the logic allows us to very easily implement usage of the downloader for cross-platform wheels. * The `whl` `METADATA` might not be exposed through older versions of Artifactory, so having the complexity hidden in this single extension allows us to not increase the complexity and scope of `pip.parse` too much. * The repository structure can be reused for `pypi_install` extension from bazel-contrib#1728. TODO: - [ ] Add unit tests for functions in `pypi_index.bzl` bzlmod extension if the design looks good. - [ ] Changelog. Out of scope of this PR: - Further usage of the downloaded artifacts to implement something similar to bazel-contrib#1625 or bazel-contrib#1744. This needs bazel-contrib#1750 and bazel-contrib#1764. - Making the lock file the same on all platforms - We would need to fully parse the requirements file. - Support for different dependency versions in the `pip.parse` hub repos based on each platform - we would need to be able to interpret platform markers in some way, but `pypi_index` should be good already. - Implementing the parsing of METADATA to detect dependency cycles. - Support for `requirements` files that are not created via `pip-compile`. - Support for other lock formats, though that would be reasonably trivial to add. Open questions: - Support for VCS dependencies in requirements files - We should probably handle them as `overrides` in the `pypi_index` extension and treat them in `pip.parse` just as an `sdist`, but I am not sure it would work without any issues.
1 parent 3f40e98 commit 6720945

File tree

9 files changed

+550
-18
lines changed

9 files changed

+550
-18
lines changed

.bazelrc

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
# (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it)
55
# To update these lines, execute
66
# `bazel run @rules_bazel_integration_test//tools:update_deleted_packages`
7-
build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/dupe_requirements,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/pythonconfig,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/pip_repository_entry_points,tests/integration/py_cc_toolchain_registered
8-
query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/dupe_requirements,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/pythonconfig,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/pip_repository_entry_points,tests/integration/py_cc_toolchain_registered
7+
build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/dupe_requirements,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/pythonconfig,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/pip_repository_entry_points,tests/integration/py_cc_toolchain_registered
8+
query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/patches,examples/bzlmod/py_proto_library,examples/bzlmod/py_proto_library/example.com/another_proto,examples/bzlmod/py_proto_library/example.com/proto,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/dupe_requirements,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,examples/py_proto_library/example.com/another_proto,examples/py_proto_library/example.com/proto,gazelle,gazelle/manifest,gazelle/manifest/generate,gazelle/manifest/hasher,gazelle/manifest/test,gazelle/modules_mapping,gazelle/python,gazelle/pythonconfig,tests/integration/compile_pip_requirements,tests/integration/compile_pip_requirements_test_from_external_repo,tests/integration/ignore_root_user_error,tests/integration/ignore_root_user_error/submodule,tests/integration/pip_parse,tests/integration/pip_parse/empty,tests/integration/pip_repository_entry_points,tests/integration/py_cc_toolchain_registered
99

1010
test --test_output=errors
1111

.bazelversion

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
7.0.0
1+
7.0.2

MODULE.bazel

+22-1
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.0")
88
bazel_dep(name = "bazel_skylib", version = "1.3.0")
99
bazel_dep(name = "platforms", version = "0.0.4")
1010

@@ -53,10 +53,31 @@ use_repo(python, "pythons_hub")
5353
# This call registers the Python toolchains.
5454
register_toolchains("@pythons_hub//:all")
5555

56+
# This call registers the `pypi_index` extension so that it can be used in the `pip` extension
57+
pypi_index = use_extension("//python/extensions:pypi_index.bzl", "pypi_index")
58+
use_repo(pypi_index, "pypi_index")
59+
5660
# ===== DEV ONLY DEPS AND SETUP BELOW HERE =====
5761
bazel_dep(name = "stardoc", version = "0.6.2", dev_dependency = True, repo_name = "io_bazel_stardoc")
5862
bazel_dep(name = "rules_bazel_integration_test", version = "0.20.0", dev_dependency = True)
5963

64+
# This call additionally only adds items to the `pypi_index` if we are
65+
# not ignoring dev dependencies, making it no-op for the regular usage.
66+
dev_pypi_index = use_extension(
67+
"//python/extensions:pypi_index.bzl",
68+
"pypi_index",
69+
dev_dependency = True,
70+
)
71+
dev_pypi_index.add_requirements(
72+
srcs = [
73+
# List all of the requirements files used by us
74+
"//docs/sphinx:requirements.txt",
75+
"//tools/publish:requirements_darwin.txt",
76+
"//tools/publish:requirements.txt",
77+
"//tools/publish:requirements_windows.txt",
78+
],
79+
)
80+
6081
dev_pip = use_extension(
6182
"//python/extensions:pip.bzl",
6283
"pip",

examples/bzlmod/MODULE.bazel

+24
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,30 @@ python.toolchain(
4343
# rules based on the `python_version` arg values.
4444
use_repo(python, "python_3_10", "python_3_9", "python_versions")
4545

46+
# This extension allows rules_python to optimize downloading for packages by checking
47+
# for available artifacts on PyPI Simple API compatible mirrors.
48+
pypi_index = use_extension("@rules_python//python/extensions:pypi_index.bzl", "pypi_index")
49+
pypi_index.add_requirements(
50+
srcs = [
51+
"//:requirements_lock_3_10.txt",
52+
"//:requirements_lock_3_9.txt",
53+
"//:requirements_windows_3_10.txt",
54+
"//:requirements_windows_3_9.txt",
55+
],
56+
)
57+
58+
# We can also initialize the extension in dev mode.
59+
dev_pypi_index = use_extension(
60+
"@rules_python//python/extensions:pypi_index.bzl",
61+
"pypi_index",
62+
dev_dependency = True,
63+
)
64+
dev_pypi_index.add_requirements(
65+
srcs = [
66+
"//tests/dupe_requirements:requirements.txt",
67+
],
68+
)
69+
4670
# This extension allows a user to create modifications to how rules_python
4771
# creates different wheel repositories. Different attributes allow the user
4872
# to modify the BUILD file, and copy files.

python/extensions/pypi_index.bzl

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright 2024 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""See the doc in the implementation file."""
16+
17+
load("//python/private/bzlmod:pypi_index.bzl", _pypi_index = "pypi_index")
18+
19+
pypi_index = _pypi_index

python/pip_install/pip_repository.bzl

+26-11
Original file line numberDiff line numberDiff line change
@@ -766,18 +766,27 @@ def _whl_library_impl(rctx):
766766
# Manually construct the PYTHONPATH since we cannot use the toolchain here
767767
environment = _create_repository_execution_environment(rctx, python_interpreter)
768768

769-
repo_utils.execute_checked(
770-
rctx,
771-
op = "whl_library.ResolveRequirement({}, {})".format(rctx.attr.name, rctx.attr.requirement),
772-
arguments = args,
773-
environment = environment,
774-
quiet = rctx.attr.quiet,
775-
timeout = rctx.attr.timeout,
776-
)
769+
if rctx.attr.whl_file:
770+
whl_path = rctx.path(rctx.attr.whl_file)
771+
if not whl_path.exists:
772+
fail("The given whl '{}' does not exist".format(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+
else:
778+
repo_utils.execute_checked(
779+
rctx,
780+
op = "whl_library.ResolveRequirement({}, {})".format(rctx.attr.name, rctx.attr.requirement),
781+
arguments = args,
782+
environment = environment,
783+
quiet = rctx.attr.quiet,
784+
timeout = rctx.attr.timeout,
785+
)
777786

778-
whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
779-
if not rctx.delete("whl_file.json"):
780-
fail("failed to delete the whl_file.json file")
787+
whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"])
788+
if not rctx.delete("whl_file.json"):
789+
fail("failed to delete the whl_file.json file")
781790

782791
if rctx.attr.whl_patches:
783792
patches = {}
@@ -911,6 +920,12 @@ whl_library_attrs = {
911920
mandatory = True,
912921
doc = "Python requirement string describing the package to make available",
913922
),
923+
"whl_file": attr.label(
924+
doc = """\
925+
The wheel file label to be used for this installation. This will not use pip to download the
926+
whl and instead use the supplied file. Note that the label needs to point to a single file.
927+
""",
928+
),
914929
"whl_patches": attr.label_keyed_string_dict(
915930
doc = """a label-keyed-string dict that has
916931
json.encode(struct([whl_file], patch_strip]) as values. This

python/private/auth.bzl

+6-3
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ def get_auth(rctx, urls):
3333
Returns:
3434
dict: A map of authentication parameters by URL.
3535
"""
36-
if rctx.attr.netrc:
37-
netrc = read_netrc(rctx, rctx.attr.netrc)
36+
attr = getattr(rctx, "attr", None)
37+
38+
if getattr(attr, "netrc", None):
39+
netrc = read_netrc(rctx, getattr(attr, "netrc"))
3840
elif "NETRC" in rctx.os.environ:
3941
netrc = read_netrc(rctx, rctx.os.environ["NETRC"])
4042
else:
4143
netrc = read_user_netrc(rctx)
42-
return use_netrc(netrc, urls, rctx.attr.auth_patterns)
44+
45+
return use_netrc(netrc, urls, getattr(attr, "auth_patterns", None))

python/private/bzlmod/pip.bzl

+51
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ You cannot use both the additive_build_content and additive_build_content_file a
101101
def _create_whl_repos(module_ctx, pip_attr, whl_map, whl_overrides):
102102
python_interpreter_target = pip_attr.python_interpreter_target
103103

104+
pypi_index_repo = module_ctx.path(pip_attr._pypi_index_repo).dirname
105+
104106
# if we do not have the python_interpreter set in the attributes
105107
# we programmatically find it.
106108
hub_name = pip_attr.hub_name
@@ -180,10 +182,46 @@ def _create_whl_repos(module_ctx, pip_attr, whl_map, whl_overrides):
180182
group_name = whl_group_mapping.get(whl_name)
181183
group_deps = requirement_cycles.get(group_name, [])
182184

185+
pkg_pypi_index = pypi_index_repo.get_child(whl_name, "index.json")
186+
if not pkg_pypi_index.exists:
187+
# The wheel index for a package does not exist, so not using bazel downloader...
188+
whl_file = None
189+
else:
190+
# Ensure that we have a wheel for a particular version.
191+
# FIXME @aignas 2024-03-10: Maybe the index structure should be:
192+
# pypi_index/<distro>/<version>:index.json?
193+
#
194+
# We expect the `requirement_line to be of shape '<distro>==<version> ...'
195+
_, _, version_tail = requirement_line.partition("==")
196+
version, _, _ = version_tail.partition(" ")
197+
version_segment = "-{}-".format(version.strip("\" "))
198+
199+
index_json = [struct(**v) for v in json.decode(module_ctx.read(pkg_pypi_index))]
200+
201+
# For now only use the whl_file if it is a cross-platform wheel.
202+
# This is very conservative and does that only thing that we have
203+
# in the whl list is the cross-platform wheel.
204+
whls = [
205+
dist
206+
for dist in index_json
207+
if dist.filename.endswith(".whl") and version_segment in dist.filename
208+
]
209+
any_whls = [
210+
dist
211+
for dist in whls
212+
if dist.filename.endswith("-none-any.whl") or dist.filename.endswith("-abi3-any.whl")
213+
]
214+
215+
if len(any_whls) == len(whls) and len(whls) == 1:
216+
whl_file = any_whls[0].label
217+
else:
218+
whl_file = None
219+
183220
repo_name = "{}_{}".format(pip_name, whl_name)
184221
whl_library(
185222
name = repo_name,
186223
requirement = requirement_line,
224+
whl_file = whl_file,
187225
repo = pip_name,
188226
repo_prefix = pip_name + "_",
189227
annotation = annotation,
@@ -414,6 +452,19 @@ a corresponding `python.toolchain()` configured.
414452
doc = """\
415453
A dict of labels to wheel names that is typically generated by the whl_modifications.
416454
The labels are JSON config files describing the modifications.
455+
""",
456+
),
457+
"_pypi_index_repo": attr.label(
458+
default = "@pypi_index//:BUILD.bazel",
459+
doc = """\
460+
The label to the root of the pypi_index repository to be used for this particular
461+
call of the `pip.parse`. This ensures that we can work with isolated usage of the
462+
pip.parse tag class, where the user may want to also have the `pypi_index` usage
463+
isolated as well.
464+
465+
This also makes the code cleaner and ensures there are no cyclic dependencies.
466+
467+
NOTE: For now this is internal and will be exposed if needed.
417468
""",
418469
),
419470
}, **pip_repository_attrs)

0 commit comments

Comments
 (0)