Skip to content

Commit ffbe7c1

Browse files
committed
feat(pypi): download and extract wheels once
1 parent fd25d1f commit ffbe7c1

11 files changed

Lines changed: 226 additions & 89 deletions

File tree

news/3978.fixed.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
(pip) Wrap `pip_parse` and `unified_workspace_hub_repo` calls in the
2+
WORKSPACE file with `maybe` from `@bazel_tools//tools/build_defs/repo:utils.bzl`
3+
so that extracted wheel dependencies are reused and not re-created when the
4+
WORKSPACE is evaluated multiple times. This mirrors the `maybe` pattern already
5+
used in the bzlmod `pip` extension and in the generated `install_deps` macro.

python/private/BUILD.bazel

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,12 @@ bzl_library(
929929
deps = [":visibility"],
930930
)
931931

932+
bzl_library(
933+
name = "repo_utils",
934+
srcs = ["repo_utils.bzl"],
935+
deps = [":text_util"],
936+
)
937+
932938
bzl_library(
933939
name = "bzlmod_enabled",
934940
srcs = ["bzlmod_enabled.bzl"],
@@ -994,11 +1000,6 @@ bzl_library(
9941000
srcs = ["py_runtime_info.bzl"],
9951001
)
9961002

997-
bzl_library(
998-
name = "repo_utils",
999-
srcs = ["repo_utils.bzl"],
1000-
)
1001-
10021003
bzl_library(
10031004
name = "runtimes_manifest_workspace",
10041005
srcs = ["runtimes_manifest_workspace.bzl"],

python/private/internal_dev_deps.bzl

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@
1414
"""Module extension for internal dev_dependency=True setup."""
1515

1616
load("@bazel_ci_rules//:rbe_repo.bzl", "rbe_preconfig")
17-
load("//python/private/pypi:whl_library.bzl", "whl_library")
17+
load("//python/private:repo_utils.bzl", "repo_utils")
18+
load("//python/private/pypi:whl_library.bzl", _whl_library = "whl_library")
1819
load("//tests/support/whl_from_dir:whl_from_dir_repo.bzl", "whl_from_dir_repo")
1920
load(":runtime_env_repo.bzl", "runtime_env_repo")
2021

2122
def _internal_dev_deps_impl(mctx):
2223
_ = mctx # @unused
24+
maybe = repo_utils.maybe({})
25+
whl_library = lambda **kwargs: _whl_library(maybe = maybe, **kwargs)
2326

2427
# Creates a default toolchain config for RBE.
2528
# Use this as is if you are using the rbe_ubuntu16_04 container,
@@ -118,6 +121,7 @@ def _internal_dev_deps_impl(mctx):
118121
root = "//tests/pypi/whl_library/testdata/pkg:BUILD.bazel",
119122
output = "pkg-1.0-any-none-any.whl",
120123
requirement = "pkg[optional]",
124+
maybe = maybe,
121125
# The following is necessary to enable pipstar and make tests faster
122126
config_load = "@rules_python//tests/pypi/whl_library/testdata:packages.bzl",
123127
dep_template = "@whl_library_extras_{name}//:{target}",
@@ -126,6 +130,7 @@ def _internal_dev_deps_impl(mctx):
126130
name = "whl_library_extras_optional_dep",
127131
root = "//tests/pypi/whl_library/testdata/optional_dep:BUILD.bazel",
128132
output = "optional_dep-1.0-any-none-any.whl",
133+
maybe = maybe,
129134
requirement = "optional_dep",
130135
# The following is necessary to enable pipstar and make tests faster
131136
config_load = "@rules_python//tests/pypi/whl_library/testdata:packages.bzl",
@@ -149,15 +154,16 @@ def _internal_dev_deps_impl(mctx):
149154
},
150155
)
151156

152-
def _whl_library_from_dir(*, name, output, root, **kwargs):
157+
def _whl_library_from_dir(*, name, output, root, maybe, **kwargs):
153158
whl_from_dir_repo(
154159
name = "{}_whl".format(name),
155160
root = root,
156161
output = output,
157162
)
158-
whl_library(
163+
_whl_library(
159164
name = name,
160165
whl_file = "@{}_whl//:{}".format(name, output),
166+
maybe = maybe,
161167
**kwargs
162168
)
163169

python/private/pypi/extension.bzl

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ load("//python/private:auth.bzl", "AUTH_ATTRS")
2222
load("//python/private:normalize_name.bzl", "normalize_name")
2323
load("//python/private:pyproject_utils.bzl", "read_pyproject", "version_from_requires_python")
2424
load("//python/private:repo_utils.bzl", "repo_utils")
25+
load("//python/private:text_util.bzl", "render")
2526
load(":hub_builder.bzl", "hub_builder")
2627
load(":hub_repository.bzl", "hub_repository", "whl_config_settings_to_json")
2728
load(":parse_whl_name.bzl", "parse_whl_name")
@@ -463,10 +464,27 @@ You cannot use both the additive_build_content and additive_build_content_file a
463464
out = hub.build()
464465

465466
for whl_name, lib in out.whl_libraries.items():
467+
# NOTE @aignas 2026-07-04: if the same wheel is downloaded from multiple
468+
# indexes, this will fail, forcing the user to actually download the wheel
469+
# from the same and deterministic location. This is usually the case for
470+
# public wheels and users should setup the defaults.index_url to correct
471+
# fall-back in rules_python we should handle the default index to substitute
472+
# any index-url in requirements pointing to the public PyPI mirrors.
466473
if whl_name in whl_libraries:
467-
fail("'{}' already in created".format(whl_name))
468-
else:
469-
whl_libraries[whl_name] = lib
474+
existing = whl_libraries[whl_name]
475+
476+
diff = repo_utils.diff_dict(existing, lib)
477+
if diff:
478+
fail("'{}' already in created:\n{}".format(
479+
whl_name,
480+
"\n".join([
481+
" {}: {}".format(key, render.indent(render.dict(value)).lstrip())
482+
for key, value in diff.items()
483+
if value
484+
]),
485+
))
486+
487+
whl_libraries[whl_name] = lib
470488

471489
exposed_packages[hub.name] = out.exposed_packages
472490
extra_aliases[hub.name] = out.extra_aliases
@@ -611,8 +629,13 @@ def _pip_impl(module_ctx):
611629
# Build all of the wheel modifications if the tag class is called.
612630
_whl_mods_impl(mods.whl_mods)
613631

632+
registered = {}
614633
for name, args in mods.whl_libraries.items():
615-
whl_library(name = name, **args)
634+
whl_library(
635+
name = name,
636+
maybe = repo_utils.maybe(registered),
637+
**args
638+
)
616639

617640
for hub_name, whl_map in mods.hub_whl_map.items():
618641
hub_repository(
@@ -991,7 +1014,7 @@ a string `"{os}_{arch}"` as the value here. You could also use `"{os}_{arch}_fre
9911014
""",
9921015
),
9931016
"uv_lock": attr.label(
994-
doc = """
1017+
doc = """\
9951018
(label, optional): A label pointing to the uv.lock file. If provided,
9961019
the uv.lock file will be used as the primary source for package metadata.
9971020

python/private/pypi/hub_builder.bzl

Lines changed: 7 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def hub_builder(
8787
# Functions to download according to the config
8888
# dict[str python_version, callable]
8989
_get_index_urls = {},
90+
_default_index_url = {},
9091
# Tells whether to use the downloader for a package.
9192
# dict[str python_version, dict[str package_name, bool use_downloader]]
9293
_use_downloader = {},
@@ -256,44 +257,6 @@ def _add_extra_aliases(self, extra_hub_aliases):
256257
{alias: True for alias in aliases},
257258
)
258259

259-
def _diff_dict(first, second):
260-
"""A simple utility to shallow compare dictionaries.
261-
262-
Args:
263-
first: The first dictionary to compare.
264-
second: The second dictionary to compare.
265-
266-
Returns:
267-
A dictionary containing the differences, with keys "common", "different",
268-
"extra", and "missing", or None if the dictionaries are identical.
269-
"""
270-
missing = {}
271-
extra = {
272-
key: value
273-
for key, value in second.items()
274-
if key not in first
275-
}
276-
common = {}
277-
different = {}
278-
279-
for key, value in first.items():
280-
if key not in second:
281-
missing[key] = value
282-
elif value == second[key]:
283-
common[key] = value
284-
else:
285-
different[key] = (value, second[key])
286-
287-
if missing or extra or different:
288-
return {
289-
"common": common,
290-
"different": different,
291-
"extra": extra,
292-
"missing": missing,
293-
}
294-
else:
295-
return None
296-
297260
def _add_whl_library(self, *, python_version, whl, repo):
298261
"""Add a whl_library and kwargs to call it with for the hub.
299262
@@ -309,16 +272,10 @@ def _add_whl_library(self, *, python_version, whl, repo):
309272
# disallow building from sdist.
310273
return
311274

312-
# TODO @aignas 2025-06-29: we should not need the version in the repo_name if
313-
# we are using pipstar and we are downloading the wheel using the downloader
314-
#
315-
# However, for that we should first have a different way to reference closures with
316-
# extras. For example, if some package depends on `foo[extra]` and another depends on
317-
# `foo`, we should have 2 py_library targets.
318275
repo_name = "{}_{}_{}".format(self.name, version_label(python_version), repo.repo_name)
319276

320277
if repo_name in self._whl_libraries:
321-
diff = _diff_dict(self._whl_libraries[repo_name], repo.args)
278+
diff = repo_utils.diff_dict(self._whl_libraries[repo_name], repo.args)
322279
if diff:
323280
self._logger.fail(lambda: (
324281
"Attempting to create a duplicate library {repo_name} for {whl_name} with different arguments. Already existing declaration has:\n".format(
@@ -348,32 +305,33 @@ def _add_whl_library(self, *, python_version, whl, repo):
348305
### end of setters, below we have various functions to implement the public methods
349306

350307
def _set_get_index_urls(self, mctx, pip_attr):
308+
python_version = pip_attr.python_version
309+
351310
# Resolve the index URL through envsubst so the ``$VAR`` / ``${VAR:-default}``
352311
# form is honored when deciding whether the experimental index-url mode is
353312
# active. Without this, an unsubstituted template like ``$RULES_PYTHON_PIP_INDEX_URL``
354313
# is treated as truthy and the mode is forced on, even when the env var
355314
# would expand to the empty string.
356-
default_index_url = envsubst(
315+
self._default_index_url[python_version] = envsubst(
357316
pip_attr.experimental_index_url,
358317
pip_attr.envsubst,
359318
mctx.getenv,
360319
) or self._config.index_url
361320
default_extra_index_urls = pip_attr.experimental_extra_index_urls or []
362321

363-
if not default_index_url:
322+
if not self._default_index_url[python_version]:
364323
# parallel_download is set to True by default, so we are not checking/validating it
365324
# here
366325
return False
367326

368-
python_version = pip_attr.python_version
369327
self._use_downloader.setdefault(python_version, {}).update({
370328
normalize_name(s): False
371329
for s in pip_attr.simpleapi_skip
372330
})
373331
self._get_index_urls[python_version] = lambda ctx, distributions, *, index_url = None, extra_index_urls = None: self._simpleapi_download_fn(
374332
ctx,
375333
attr = struct(
376-
index_url = (index_url or default_index_url).rstrip("/"),
334+
index_url = (index_url or self._default_index_url[python_version]).rstrip("/"),
377335
extra_index_urls = [
378336
x.rstrip("/")
379337
for x in (extra_index_urls or default_extra_index_urls)

python/private/pypi/whl_library.bzl

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
""
1616

17+
load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
1718
load("//python/private:auth.bzl", "AUTH_ATTRS", "get_auth")
1819
load("//python/private:envsubst.bzl", "envsubst")
1920
load("//python/private:is_standalone_interpreter.bzl", "is_standalone_interpreter")
@@ -28,6 +29,7 @@ load(":pypi_repo_utils.bzl", "pypi_repo_utils")
2829
load(":urllib.bzl", "urllib")
2930
load(":whl_extract.bzl", "whl_extract")
3031
load(":whl_metadata.bzl", "parse_entry_points", "whl_metadata")
32+
load(":whl_repo_name.bzl", "whl_repo_name")
3133

3234
_CPPFLAGS = "CPPFLAGS"
3335
_COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools"
@@ -736,7 +738,7 @@ def _whl_deps_library_impl(rctx):
736738
repo = rctx.attr.repo or (
737739
str(rctx.attr.metadata_file) if rctx.attr.metadata_file else None
738740
),
739-
extras = requirement(rctx.attr.requirement).extras,
741+
extras = rctx.attr.extras,
740742
)
741743
rctx.file("BUILD.bazel", build_file_contents)
742744

@@ -748,9 +750,10 @@ whl_deps_library = repository_rule(
748750
"dep_template",
749751
"group_deps",
750752
"group_name",
751-
"requirement",
753+
"repo_prefix",
752754
]
753755
} | {
756+
"extras": attr.string_list(doc = "The extras to configure for this repo."),
754757
"metadata": attr.string(
755758
doc = """
756759
The subset of the METADATA contents that is needed for generation of the dependencies.
@@ -773,7 +776,7 @@ Does not depend on any python.
773776
environ = [REPO_DEBUG_ENV_VAR],
774777
)
775778

776-
def whl_library(name, repo = None, **kwargs):
779+
def whl_library(name, repo = None, maybe = maybe, **kwargs):
777780
"""Create a whl_library.
778781
779782
This proxies to one of the underlying implementations:
@@ -783,6 +786,9 @@ def whl_library(name, repo = None, **kwargs):
783786
Args:
784787
name: {type}`str` The name of the repo.
785788
repo: Unused, will be dropped in the next major release.
789+
maybe: This is the repo rule that is used in WORKSPACE mode and in the extension eval to
790+
dedupe some of the invocations. This has to be overridden on bzlmod using
791+
{obj}`repo_utils.bzlmod_maybe`.
786792
**kwargs: The args passed to the underlying implementation.
787793
788794
Returns:
@@ -794,6 +800,80 @@ def whl_library(name, repo = None, **kwargs):
794800
urls = kwargs.get("urls", [])
795801
filename = kwargs.get("filename")
796802
if whl_file or (urls and filename and filename.endswith(".whl")):
797-
whl_archive(name = name, **kwargs)
803+
filename = filename or Label(whl_file).name
804+
if "annotation" in kwargs or "whl_patches" in kwargs:
805+
# No reuse of the whl_library because there is an annotation here
806+
whl_archive(name = name, **kwargs)
807+
return
808+
809+
extract_args = {
810+
k: v
811+
for k, v in kwargs.items()
812+
if k not in {
813+
# TODO @aignas 2026-08-01: what about python_interpreter and python_interpreter_target
814+
"config_load": None,
815+
"dep_template": None,
816+
}
817+
}
818+
if "index_url" in extract_args:
819+
# TODO @aignas 2026-08-01: figure out where we should do the fix here.
820+
extract_args["index_url"] = extract_args["index_url"].strip("/")
821+
822+
# The extras do not affect the extraction, so normalize the requirement
823+
# to allow the same wheel to be extracted only once.
824+
extract_args["requirement"] = _without_extras(extract_args["requirement"])
825+
extract_repo_name = "w_{}".format(
826+
whl_repo_name(filename, kwargs.get("sha256")),
827+
)
828+
829+
maybe(
830+
whl_archive,
831+
name = extract_repo_name,
832+
**extract_args
833+
)
834+
835+
req = requirement(kwargs["requirement"])
836+
837+
deps_args = {
838+
k: kwargs.get(k)
839+
for k in [
840+
"config_load",
841+
"dep_template",
842+
"group_deps",
843+
"group_name",
844+
]
845+
if kwargs.get(k) != None
846+
} | {
847+
# TODO @aignas 2026-08-01: add extras only if the list is non-empty
848+
"extras": req.extras,
849+
"metadata_file": "@{}//:metadata.json".format(extract_repo_name),
850+
}
851+
whl_deps_library(
852+
name = name,
853+
**deps_args
854+
)
798855
else:
856+
# No reuse of the whl_library because we have args that force the extraction of the whl
857+
# in the hub context. If we have whl-only pipstar extraction, then we can reuse the
858+
# extracted sources.
799859
pip_archive(name = name, **kwargs)
860+
861+
def _without_extras(requirement_line):
862+
"""Remove the extras from a requirement line.
863+
864+
The extras do not affect which wheel is downloaded and extracted, so they
865+
can be removed to allow the same wheel to be extracted only once even when
866+
it is referenced with different extras.
867+
868+
Args:
869+
requirement_line: {type}`str` the requirement line, e.g.
870+
`foo[bar]==1.0 --hash=sha256:...`.
871+
872+
Returns:
873+
The requirement line without the extras, e.g. `foo==1.0 --hash=sha256:...`.
874+
"""
875+
name_and_version, _, extras = requirement_line.partition("[")
876+
if not extras:
877+
return requirement_line
878+
_, _, rest = extras.partition("]")
879+
return name_and_version + rest

0 commit comments

Comments
 (0)