Skip to content

Commit a43c2f2

Browse files
committed
feat(pypi): restrict pip.parse hub visibility
Add a restrict_visibility_to attribute for bzlmod pip.parse so hub aliases are public only for packages listed in direct requirement files. Keep all lockfile packages available to generated wheel repositories so transitive dependencies continue to resolve internally. Fixes #3413
1 parent 83f714d commit a43c2f2

11 files changed

Lines changed: 297 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ END_UNRELEASED_TEMPLATE
9696
* Python toolchain from [20260414] release.
9797
* (pypi) `package_metadata` support, fixes
9898
[#2054](https://github.com/bazel-contrib/rules_python/issues/2054).
99+
* (pypi) Added {attr}`pip.parse.restrict_visibility_to` to expose only
100+
packages listed in requirement files while keeping lockfile transitive
101+
dependencies available internally. Fixes
102+
[#3413](https://github.com/bazel-contrib/rules_python/issues/3413).
99103

100104
[20260325]: https://github.com/astral-sh/python-build-standalone/releases/tag/20260325
101105
[20260414]: https://github.com/astral-sh/python-build-standalone/releases/tag/20260414

docs/pypi/download.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,30 @@ pip.parse(
2424
use_repo(pip, "my_deps")
2525
```
2626

27-
For more documentation, see the Bzlmod examples under the {gh-path}`examples` folder or the documentation
28-
for the {obj}`@rules_python//python/extensions:pip.bzl` extension.
27+
For more documentation, see the Bzlmod examples under the
28+
{gh-path}`examples` folder or the documentation for the
29+
{obj}`@rules_python//python/extensions:pip.bzl` extension.
30+
31+
## Restricting exposed hub packages
32+
33+
By default, every package in {attr}`pip.parse.requirements_lock` gets a public
34+
hub alias, such as `@my_deps//foo`. If you want only direct dependencies to be
35+
available to user code, set {attr}`pip.parse.restrict_visibility_to` to one or
36+
more requirement files that list those direct packages:
37+
38+
```starlark
39+
pip.parse(
40+
hub_name = "my_deps",
41+
python_version = "3.13",
42+
requirements_lock = "//:requirements_lock.txt",
43+
restrict_visibility_to = ["//:requirements.in"],
44+
)
45+
```
46+
47+
Packages in the lock file that are not listed in the restricted requirement
48+
files still get generated wheel repositories, so direct dependencies can use
49+
their transitive dependencies. Their hub aliases are visible only to the
50+
generated wheel repositories and are not public targets for user code.
2951

3052
:::note}
3153
We are using a host-platform compatible toolchain by default to setup pip dependencies.

python/private/pypi/extension.bzl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,21 @@ The Python version the dependencies are targetting, in Major.Minor format
786786
If an interpreter isn't explicitly provided (using `python_interpreter` or
787787
`python_interpreter_target`), then the version specified here must have
788788
a corresponding `python.toolchain()` configured.
789+
""",
790+
),
791+
"restrict_visibility_to": attr.label_list(
792+
allow_files = True,
793+
doc = """
794+
A list of requirement files whose package names are exposed as public hub
795+
targets. Packages in the lock files that are not listed here still get wheel
796+
repositories so they can be used as transitive dependencies, but their hub
797+
aliases are only visible to repositories generated by this `pip.parse` hub.
798+
799+
This is useful when your lock file contains transitive dependencies that should
800+
remain implementation details of your direct dependencies.
801+
802+
:::{versionadded} VERSION_NEXT_FEATURE
803+
:::
789804
""",
790805
),
791806
"simpleapi_skip": attr.string_list(

python/private/pypi/hub_builder.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ def _create_whl_repos(
510510
extra_pip_args = pip_attr.extra_pip_args,
511511
get_index_urls = self._get_index_urls.get(pip_attr.python_version),
512512
evaluate_markers = _evaluate_markers(self, pip_attr),
513+
exposed_requirements = pip_attr.restrict_visibility_to,
513514
logger = logger,
514515
)
515516

python/private/pypi/hub_repository.bzl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ exports_files(["requirements.bzl"])
2626
"""
2727

2828
def _impl(rctx):
29-
bzl_packages = rctx.attr.packages or rctx.attr.whl_map.keys()
29+
bzl_packages = rctx.attr.packages
3030
aliases = render_multiplatform_pkg_aliases(
3131
aliases = {
3232
key: _whl_config_settings_from_json(values)
3333
for key, values in rctx.attr.whl_map.items()
3434
},
35+
exposed_packages = bzl_packages,
3536
extra_hub_aliases = rctx.attr.extra_hub_aliases,
3637
requirement_cycles = rctx.attr.groups,
3738
platform_config_settings = rctx.attr.platform_config_settings,
@@ -81,7 +82,8 @@ hub_repository = repository_rule(
8182
"packages": attr.string_list(
8283
mandatory = False,
8384
doc = """\
84-
The list of packages that will be exposed via all_*requirements macros. Defaults to whl_map keys.
85+
The list of packages that will be exposed via public hub aliases and
86+
all_*requirements macros.
8587
""",
8688
),
8789
"platform_config_settings": attr.string_list_dict(

python/private/pypi/parse_requirements.bzl

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def parse_requirements(
4242
platforms = {},
4343
get_index_urls = None,
4444
evaluate_markers = None,
45+
exposed_requirements = [],
4546
extract_url_srcs = True,
4647
logger):
4748
"""Get the requirements with platforms that the requirements apply to.
@@ -62,6 +63,9 @@ def parse_requirements(
6263
the platforms stored as values in the input dict. Returns the same
6364
dict, but with values being platforms that are compatible with the
6465
requirements line.
66+
exposed_requirements: List of requirements files. When present, only
67+
packages listed in these files should be exposed via the hub
68+
repository.
6569
extract_url_srcs: A boolean to enable extracting URLs from requirement
6670
lines to enable using bazel downloader.
6771
logger: repo_utils.logger, a simple struct to log diagnostic messages.
@@ -92,6 +96,7 @@ def parse_requirements(
9296
reqs_with_env_markers = {}
9397
index_url = None
9498
extra_index_urls = []
99+
exposed_package_names = _exposed_package_names(ctx, exposed_requirements)
95100
for file, plats in requirements_by_platform.items():
96101
logger.trace(lambda: "Using {} for {}".format(file, plats))
97102
contents = ctx.read(file)
@@ -231,17 +236,34 @@ def parse_requirements(
231236
# for p in dist.target_platforms
232237
# ]
233238

239+
normalized_name = normalize_name(name)
240+
is_exposed = len(requirement_target_platforms) == len(requirements)
241+
if exposed_package_names != None and normalized_name not in exposed_package_names:
242+
is_exposed = False
243+
234244
item = struct(
235245
# Return normalized names
236-
name = normalize_name(name),
237-
is_exposed = len(requirement_target_platforms) == len(requirements),
246+
name = normalized_name,
247+
is_exposed = is_exposed,
238248
is_multiple_versions = len(reqs.values()) > 1,
239249
index_url = pkg_sources.index_url if pkg_sources else "",
240250
srcs = package_srcs,
241251
)
242252
ret.append(item)
243-
if not item.is_exposed and logger:
244-
logger.trace(lambda: "Package '{}' will not be exposed because it is only present on a subset of platforms: {} out of {}".format(
253+
if (
254+
exposed_package_names != None and
255+
normalized_name not in exposed_package_names and
256+
logger
257+
):
258+
logger.trace(lambda: (
259+
"Package '{}' will not be exposed because it is not present " +
260+
"in restrict_visibility_to"
261+
).format(name))
262+
if len(requirement_target_platforms) != len(requirements) and logger:
263+
logger.trace(lambda: (
264+
"Package '{}' will not be exposed because it is only present " +
265+
"on a subset of platforms: {} out of {}"
266+
).format(
245267
name,
246268
sorted(requirement_target_platforms),
247269
sorted(requirements),
@@ -251,6 +273,19 @@ def parse_requirements(
251273

252274
return ret
253275

276+
def _exposed_package_names(ctx, exposed_requirements):
277+
"""Parse the requirement files that define hub-exposed package names."""
278+
if not exposed_requirements:
279+
return None
280+
281+
exposed = {}
282+
for file in exposed_requirements:
283+
parse_result = parse_requirements_txt(ctx.read(file))
284+
for distribution, _ in parse_result.requirements:
285+
exposed[normalize_name(distribution)] = None
286+
287+
return exposed
288+
254289
def _package_srcs(
255290
*,
256291
name,

python/private/pypi/render_pkg_aliases.bzl

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def _repr_actual(aliases):
6565
else:
6666
return render.dict(aliases, key_repr = _repr_config_setting)
6767

68-
def _render_common_aliases(*, name, aliases, **kwargs):
68+
def _render_common_aliases(*, name, aliases, visibility, **kwargs):
6969
pkg_aliases = render.call(
7070
"pkg_aliases",
7171
name = repr(name),
@@ -80,14 +80,21 @@ def _render_common_aliases(*, name, aliases, **kwargs):
8080
return """\
8181
load("@rules_python//python/private/pypi:pkg_aliases.bzl", "pkg_aliases")
8282
{extra_loads}
83-
package(default_visibility = ["//visibility:public"])
83+
package(default_visibility = {visibility})
8484
8585
{aliases}""".format(
8686
aliases = pkg_aliases,
8787
extra_loads = extra_loads,
88+
visibility = render.list(visibility),
8889
)
8990

90-
def render_pkg_aliases(*, aliases, requirement_cycles = None, extra_hub_aliases = {}, **kwargs):
91+
def render_pkg_aliases(
92+
*,
93+
aliases,
94+
requirement_cycles = None,
95+
extra_hub_aliases = {},
96+
exposed_packages = None,
97+
**kwargs):
9198
"""Create alias declarations for each PyPI package.
9299
93100
The aliases should be appended to the pip_repository BUILD.bazel file. These aliases
@@ -100,6 +107,8 @@ def render_pkg_aliases(*, aliases, requirement_cycles = None, extra_hub_aliases
100107
requirement_cycles: any package groups to also add.
101108
extra_hub_aliases: The list of extra aliases for each whl to be added
102109
in addition to the default ones.
110+
exposed_packages: The public hub packages. When present, other packages
111+
are only visible to generated wheel repositories.
103112
**kwargs: Extra kwargs to pass to the rules.
104113
105114
Returns:
@@ -124,12 +133,20 @@ def render_pkg_aliases(*, aliases, requirement_cycles = None, extra_hub_aliases
124133
for whl_name in group_whls
125134
}
126135

136+
exposed_packages = _normalize_package_names(exposed_packages)
137+
internal_visibility = _internal_visibility(aliases)
138+
127139
files = {
128140
"{}/BUILD.bazel".format(normalize_name(name)): _render_common_aliases(
129141
name = normalize_name(name),
130142
aliases = pkg_aliases,
131143
extra_aliases = extra_hub_aliases.get(normalize_name(name), []),
132144
group_name = whl_group_mapping.get(normalize_name(name)),
145+
visibility = _package_visibility(
146+
name = normalize_name(name),
147+
exposed_packages = exposed_packages,
148+
internal_visibility = internal_visibility,
149+
),
133150
**kwargs
134151
).strip()
135152
for name, pkg_aliases in aliases.items()
@@ -139,6 +156,36 @@ def render_pkg_aliases(*, aliases, requirement_cycles = None, extra_hub_aliases
139156
files["_groups/BUILD.bazel"] = generate_group_library_build_bazel("", requirement_cycles)
140157
return files
141158

159+
def _normalize_package_names(packages):
160+
if packages == None:
161+
return None
162+
163+
return {
164+
normalize_name(package): None
165+
for package in packages
166+
}
167+
168+
def _internal_visibility(aliases):
169+
repo_names = {}
170+
for pkg_aliases in aliases.values():
171+
if type(pkg_aliases) == type(""):
172+
repo_names[pkg_aliases] = None
173+
continue
174+
175+
for repo_name in pkg_aliases.values():
176+
repo_names[repo_name] = None
177+
178+
return [
179+
"@{}//:__pkg__".format(repo_name)
180+
for repo_name in sorted(repo_names)
181+
]
182+
183+
def _package_visibility(*, name, exposed_packages, internal_visibility):
184+
if exposed_packages == None or name in exposed_packages:
185+
return ["//visibility:public"]
186+
187+
return internal_visibility
188+
142189
def _major_minor(python_version):
143190
major, _, tail = python_version.partition(".")
144191
minor, _, _ = tail.partition(".")

tests/pypi/extension/pip_parse.bzl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def pip_parse(
2727
requirements_linux = None,
2828
requirements_lock = None,
2929
requirements_windows = None,
30+
restrict_visibility_to = [],
3031
target_platforms = [],
3132
simpleapi_skip = [],
3233
timeout = 600,
@@ -60,6 +61,7 @@ def pip_parse(
6061
requirements_linux = requirements_linux,
6162
requirements_lock = requirements_lock,
6263
requirements_windows = requirements_windows,
64+
restrict_visibility_to = restrict_visibility_to,
6365
timeout = timeout,
6466
whl_modifications = whl_modifications,
6567
parallel_download = False,

tests/pypi/hub_builder/hub_builder_tests.bzl

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,65 @@ def _test_simple(env):
153153

154154
_tests.append(_test_simple)
155155

156+
def _test_restrict_visibility_to(env):
157+
builder = hub_builder(env)
158+
builder.pip_parse(
159+
_mock_mctx(
160+
os_name = "osx",
161+
arch_name = "aarch64",
162+
mock_files = {
163+
"requirements.in": "foo>=0.0.1\n",
164+
"requirements.txt": """\
165+
foo==0.0.1 --hash=sha256:deadbeef
166+
dep-of-foo==0.0.1 --hash=sha256:deadb00f
167+
""",
168+
},
169+
),
170+
_parse(
171+
hub_name = "pypi",
172+
python_version = "3.15",
173+
requirements_lock = "requirements.txt",
174+
restrict_visibility_to = ["requirements.in"],
175+
),
176+
)
177+
pypi = builder.build()
178+
179+
pypi.exposed_packages().contains_exactly(["foo"])
180+
pypi.group_map().contains_exactly({})
181+
pypi.whl_map().contains_exactly({
182+
"dep_of_foo": {
183+
"pypi_315_dep_of_foo": [
184+
whl_config_setting(
185+
version = "3.15",
186+
),
187+
],
188+
},
189+
"foo": {
190+
"pypi_315_foo": [
191+
whl_config_setting(
192+
version = "3.15",
193+
),
194+
],
195+
},
196+
})
197+
pypi.whl_libraries().contains_exactly({
198+
"pypi_315_dep_of_foo": {
199+
"config_load": "@pypi//:config.bzl",
200+
"dep_template": "@pypi//{name}:{target}",
201+
"python_interpreter_target": "unit_test_interpreter_target",
202+
"requirement": "dep-of-foo==0.0.1 --hash=sha256:deadb00f",
203+
},
204+
"pypi_315_foo": {
205+
"config_load": "@pypi//:config.bzl",
206+
"dep_template": "@pypi//{name}:{target}",
207+
"python_interpreter_target": "unit_test_interpreter_target",
208+
"requirement": "foo==0.0.1 --hash=sha256:deadbeef",
209+
},
210+
})
211+
pypi.extra_aliases().contains_exactly({})
212+
213+
_tests.append(_test_restrict_visibility_to)
214+
156215
def _test_simple_multiple_requirements(env):
157216
sub_tests = {
158217
("osx", "aarch64"): "simple==0.0.2 --hash=sha256:deadb00f",

0 commit comments

Comments
 (0)