Skip to content

Commit ace01f7

Browse files
committed
Add local wheel support
1 parent 5511aaf commit ace01f7

5 files changed

Lines changed: 326 additions & 12 deletions

File tree

python/private/pypi/extension.bzl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ You cannot use both the additive_build_content and additive_build_content_file a
381381
builder.pip_parse(
382382
module_ctx,
383383
pip_attr = pip_attr,
384+
is_root = mod.is_root,
384385
)
385386

386387
# Keeps track of all the hub's whl repos across the different versions.
@@ -827,6 +828,9 @@ A dict of labels to wheel names that is typically generated by the whl_modificat
827828
The labels are JSON config files describing the modifications.
828829
""",
829830
),
831+
"local_wheels": attr.string_dict(
832+
doc = "Dictionary mapping package names to local wheel file paths relative to the workspace root.",
833+
),
830834
}, **ATTRS)
831835
attrs.update(AUTH_ATTRS)
832836

python/private/pypi/hub_builder.bzl

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def _build(self):
147147
whl_libraries = self._whl_libraries,
148148
)
149149

150-
def _pip_parse(self, module_ctx, pip_attr):
150+
def _pip_parse(self, module_ctx, pip_attr, is_root = False):
151151
python_version = pip_attr.python_version
152152
if python_version in self._platforms:
153153
fail((
@@ -194,6 +194,7 @@ def _pip_parse(self, module_ctx, pip_attr):
194194
self,
195195
module_ctx,
196196
pip_attr = pip_attr,
197+
is_root = is_root,
197198
enable_pipstar_extract = bool(self._config.enable_pipstar_extract or self._get_index_urls.get(pip_attr.python_version)),
198199
)
199200

@@ -332,7 +333,7 @@ def _add_whl_library(self, *, python_version, whl, repo):
332333
if value
333334
])
334335
))
335-
return
336+
return
336337
self._whl_libraries[repo_name] = repo.args
337338

338339
mapping = self._whl_map.setdefault(whl.name, {})
@@ -479,6 +480,7 @@ def _create_whl_repos(
479480
module_ctx,
480481
*,
481482
pip_attr,
483+
is_root = False,
482484
enable_pipstar_extract = False):
483485
"""create all of the whl repositories
484486
@@ -532,7 +534,10 @@ def _create_whl_repos(
532534

533535
interpreter = _detect_interpreter(self, pip_attr)
534536

537+
local_wheels = _collect_local_wheels(module_ctx, pip_attr, is_root = is_root)
538+
535539
for whl in requirements_by_platform:
540+
local_wheel = local_wheels.get(whl.name)
536541
whl_library_args = common_args | _whl_library_args(
537542
self,
538543
whl = whl,
@@ -550,6 +555,7 @@ def _create_whl_repos(
550555
python_version = _major_minor_version(pip_attr.python_version),
551556
is_multiple_versions = whl.is_multiple_versions,
552557
interpreter = interpreter,
558+
local_wheel = local_wheel,
553559
enable_pipstar_extract = enable_pipstar_extract,
554560
)
555561
_add_whl_library(
@@ -625,6 +631,7 @@ def _whl_repo(
625631
python_version,
626632
use_downloader,
627633
interpreter,
634+
local_wheel = None,
628635
enable_pipstar_extract = False):
629636
args = dict(whl_library_args)
630637
args["requirement"] = src.requirement_line
@@ -681,9 +688,17 @@ def _whl_repo(
681688
# targets to each hub for each extra combination and solve this more cleanly as opposed to
682689
# duplicating whl_library repositories.
683690
target_platforms = src.target_platforms if is_multiple_versions else []
691+
repo_name = whl_repo_name(src.filename, src.sha256, *target_platforms)
692+
693+
if local_wheel:
694+
repo_name += "_local_override"
695+
path_str = local_wheel._path if hasattr(local_wheel, "_path") else str(local_wheel)
696+
args["urls"] = ["file://" + path_str]
697+
args["filename"] = local_wheel.basename
698+
args["sha256"] = ""
684699

685700
return struct(
686-
repo_name = whl_repo_name(src.filename, src.sha256, *target_platforms),
701+
repo_name = repo_name,
687702
args = args,
688703
config_setting = whl_config_setting(
689704
version = python_version,
@@ -696,3 +711,61 @@ def _use_downloader(self, python_version, whl_name):
696711
normalize_name(whl_name),
697712
self._get_index_urls.get(python_version) != None,
698713
)
714+
715+
def _collect_local_wheels(module_ctx, pip_attr, is_root = False):
716+
if not is_root:
717+
return {}
718+
719+
wheels = {}
720+
explicit_wheels = getattr(pip_attr, "local_wheels", None)
721+
if not explicit_wheels:
722+
return wheels
723+
724+
workspace_root = module_ctx.path(Label("@@//:MODULE.bazel")).dirname
725+
726+
for pkg_name, wheel_path_str in explicit_wheels.items():
727+
norm_name = normalize_name(pkg_name)
728+
if "*" not in wheel_path_str:
729+
wheel_path = workspace_root.get_child(wheel_path_str)
730+
if wheel_path.exists:
731+
wheels[norm_name] = wheel_path
732+
else:
733+
last_slash = wheel_path_str.rfind("/")
734+
if last_slash >= 0:
735+
dir_part = wheel_path_str[:last_slash]
736+
pattern = wheel_path_str[last_slash + 1:]
737+
else:
738+
dir_part = ""
739+
pattern = wheel_path_str
740+
741+
matched_wheel = None
742+
target_dir = workspace_root.get_child(dir_part) if dir_part else workspace_root
743+
if target_dir.exists:
744+
candidates = target_dir.readdir()
745+
else:
746+
candidates = []
747+
748+
for candidate in candidates:
749+
if not candidate.basename.endswith(".whl"):
750+
continue
751+
if _wildcard_match(candidate.basename, pattern):
752+
if not matched_wheel or matched_wheel.basename < candidate.basename:
753+
matched_wheel = candidate
754+
755+
if matched_wheel:
756+
wheels[norm_name] = matched_wheel
757+
758+
return wheels
759+
760+
def _wildcard_match(name, pattern):
761+
if pattern.startswith("*") and pattern.endswith("*"):
762+
return name.find(pattern[1:-1]) >= 0
763+
elif pattern.startswith("*"):
764+
return name.endswith(pattern[1:])
765+
elif pattern.endswith("*"):
766+
return name.startswith(pattern[:-1])
767+
elif "*" in pattern:
768+
parts = pattern.split("*", 1)
769+
return name.startswith(parts[0]) and name.endswith(parts[1]) and len(name) >= len(parts[0]) + len(parts[1])
770+
else:
771+
return name == pattern

tests/pypi/hub_builder/hub_builder_tests.bzl

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,110 @@ simple==0.0.1 --hash=sha256:deadb00f
828828

829829
_tests.append(_test_index_url_precedence)
830830

831+
def _test_local_wheel_override(env):
832+
def mock_simpleapi_download(*_, **__):
833+
return {
834+
"simple": struct(
835+
whls = {
836+
"deadbeef": struct(
837+
yanked = None,
838+
filename = "simple-0.0.1-py3-none-any.whl",
839+
sha256 = "deadbeef",
840+
url = "example.com/simple-0.0.1.whl",
841+
),
842+
},
843+
sdists = {},
844+
sha256s_by_version = {},
845+
index_url = "https://example.com",
846+
),
847+
}
848+
849+
builder = hub_builder(
850+
env,
851+
simpleapi_download_fn = mock_simpleapi_download,
852+
)
853+
builder.pip_parse(
854+
mocks.mctx(
855+
mock_files = {
856+
"requirements.txt": "simple==0.0.1 --hash=sha256:deadbeef",
857+
"MODULE.bazel": "",
858+
"dist/simple-0.0.2-cp315-cp315-linux_x86_64.whl": "",
859+
},
860+
os_name = "linux",
861+
arch_name = "x86_64",
862+
),
863+
_parse(
864+
hub_name = "pypi",
865+
python_version = "3.15",
866+
experimental_index_url = "https://example.com",
867+
requirements_lock = "requirements.txt",
868+
local_wheels = {
869+
"simple": "dist/simple-0.0.2-cp315-cp315-linux_x86_64.whl",
870+
},
871+
),
872+
is_root = True,
873+
)
874+
pypi = builder.build()
875+
876+
pypi.exposed_packages().contains_exactly(["simple"])
877+
pypi.whl_map().contains_exactly({
878+
"simple": {
879+
"pypi_315_simple_py3_none_any_deadbeef_local_override": [
880+
whl_config_setting(version = "3.15", target_platforms = ["cp315_linux_x86_64"]),
881+
],
882+
},
883+
})
884+
pypi.whl_libraries().contains_exactly({
885+
"pypi_315_simple_py3_none_any_deadbeef_local_override": {
886+
"config_load": "@pypi//:config.bzl",
887+
"dep_template": "@pypi//{name}:{target}",
888+
"filename": "simple-0.0.2-cp315-cp315-linux_x86_64.whl",
889+
"index_url": "https://example.com",
890+
"requirement": "simple==0.0.1",
891+
"sha256": "",
892+
"urls": ["file://dist/simple-0.0.2-cp315-cp315-linux_x86_64.whl"],
893+
},
894+
})
895+
pypi.extra_aliases().contains_exactly({})
896+
897+
_tests.append(_test_local_wheel_override)
898+
899+
def _test_local_wheel_override_ignored_if_not_root(env):
900+
builder = hub_builder(env)
901+
builder.pip_parse(
902+
mocks.mctx(
903+
mock_files = {
904+
"requirements.txt": "simple==0.0.1 --hash=sha256:deadbeef",
905+
"MODULE.bazel": "",
906+
"dist/simple-0.0.2-cp315-cp315-linux_x86_64.whl": "",
907+
},
908+
os_name = "linux",
909+
arch_name = "x86_64",
910+
),
911+
_parse(
912+
hub_name = "pypi",
913+
python_version = "3.15",
914+
requirements_lock = "requirements.txt",
915+
local_wheels = {
916+
"simple": "dist/simple-0.0.2-cp315-cp315-linux_x86_64.whl",
917+
},
918+
),
919+
is_root = False,
920+
)
921+
pypi = builder.build()
922+
923+
pypi.exposed_packages().contains_exactly(["simple"])
924+
pypi.whl_libraries().contains_exactly({
925+
"pypi_315_simple": {
926+
"config_load": "@pypi//:config.bzl",
927+
"dep_template": "@pypi//{name}:{target}",
928+
"python_interpreter_target": "unit_test_interpreter_target",
929+
"requirement": "simple==0.0.1 --hash=sha256:deadbeef",
930+
},
931+
})
932+
933+
_tests.append(_test_local_wheel_override_ignored_if_not_root)
934+
831935
def _test_download_only_multiple(env):
832936
builder = hub_builder(env)
833937
builder.pip_parse(
@@ -1495,6 +1599,93 @@ Attempting to create a duplicate library pypi_315_foo for foo with different arg
14951599

14961600
_tests.append(_test_err_duplicate_repos)
14971601

1602+
def _test_explicit_local_wheels(env):
1603+
def mock_simpleapi_download(*_, **__):
1604+
return {
1605+
"simple": struct(
1606+
whls = {
1607+
"deadbeef": struct(
1608+
yanked = None,
1609+
filename = "simple-0.0.1-py3-none-any.whl",
1610+
sha256 = "deadbeef",
1611+
url = "example.com/simple-0.0.1.whl",
1612+
),
1613+
},
1614+
sdists = {},
1615+
sha256s_by_version = {},
1616+
index_url = "https://example.com",
1617+
),
1618+
"libtpu": struct(
1619+
whls = {
1620+
"deadbaaf": struct(
1621+
yanked = None,
1622+
filename = "libtpu-0.0.1-py3-none-any.whl",
1623+
sha256 = "deadbaaf",
1624+
url = "example.com/libtpu-0.0.1.whl",
1625+
),
1626+
},
1627+
sdists = {},
1628+
sha256s_by_version = {},
1629+
index_url = "https://example.com",
1630+
),
1631+
}
1632+
1633+
builder = hub_builder(
1634+
env,
1635+
simpleapi_download_fn = mock_simpleapi_download,
1636+
)
1637+
builder.pip_parse(
1638+
mocks.mctx(
1639+
mock_files = {
1640+
"requirements.txt": """\
1641+
simple==0.0.1 --hash=sha256:deadbeef
1642+
libtpu==0.0.1 --hash=sha256:deadbaaf
1643+
""",
1644+
"MODULE.bazel": "",
1645+
"custom_folder/libtpu-0.0.41.dev20260509+nightly-cp314-cp314t-manylinux_2_31_x86_64.whl": "",
1646+
"custom_folder/simple-0.0.3-py3-none-any.whl": "",
1647+
},
1648+
os_name = "linux",
1649+
arch_name = "x86_64",
1650+
),
1651+
_parse(
1652+
hub_name = "pypi",
1653+
python_version = "3.15",
1654+
experimental_index_url = "https://example.com",
1655+
requirements_lock = "requirements.txt",
1656+
local_wheels = {
1657+
"simple": "custom_folder/simple-0.0.3-py3-none-any.whl",
1658+
"libtpu": "custom_folder/libtpu-*.whl",
1659+
},
1660+
),
1661+
is_root = True,
1662+
)
1663+
pypi = builder.build()
1664+
1665+
pypi.exposed_packages().contains_exactly(["libtpu", "simple"])
1666+
pypi.whl_libraries().contains_exactly({
1667+
"pypi_315_libtpu_py3_none_any_deadbaaf_local_override": {
1668+
"config_load": "@pypi//:config.bzl",
1669+
"dep_template": "@pypi//{name}:{target}",
1670+
"filename": "libtpu-0.0.41.dev20260509+nightly-cp314-cp314t-manylinux_2_31_x86_64.whl",
1671+
"index_url": "https://example.com",
1672+
"requirement": "libtpu==0.0.1",
1673+
"sha256": "",
1674+
"urls": ["file://custom_folder/libtpu-0.0.41.dev20260509+nightly-cp314-cp314t-manylinux_2_31_x86_64.whl"],
1675+
},
1676+
"pypi_315_simple_py3_none_any_deadbeef_local_override": {
1677+
"config_load": "@pypi//:config.bzl",
1678+
"dep_template": "@pypi//{name}:{target}",
1679+
"filename": "simple-0.0.3-py3-none-any.whl",
1680+
"index_url": "https://example.com",
1681+
"requirement": "simple==0.0.1",
1682+
"sha256": "",
1683+
"urls": ["file://custom_folder/simple-0.0.3-py3-none-any.whl"],
1684+
},
1685+
})
1686+
1687+
_tests.append(_test_explicit_local_wheels)
1688+
14981689
def hub_builder_test_suite(name):
14991690
"""Create the test suite.
15001691

0 commit comments

Comments
 (0)