Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ END_UNRELEASED_TEMPLATE
* (pypi) `package_metadata` support, fixes
[#2054](https://github.com/bazel-contrib/rules_python/issues/2054).
* (coverage) Add support for python 3.14 and bump `coverage.py` to 7.10.7.
* (pypi) Added {attr}`pip.parse.srcs` to expose only packages listed in
requirement source files while keeping lockfile transitive dependencies
available internally, and to create a generated `lock.update` target in the
hub repository. Fixes
[#3413](https://github.com/bazel-contrib/rules_python/issues/3413).

[20260325]: https://github.com/astral-sh/python-build-standalone/releases/tag/20260325
[20260414]: https://github.com/astral-sh/python-build-standalone/releases/tag/20260414
Expand Down
41 changes: 39 additions & 2 deletions docs/pypi/download.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,45 @@ pip.parse(
use_repo(pip, "my_deps")
```

For more documentation, see the Bzlmod examples under the {gh-path}`examples` folder or the documentation
for the {obj}`@rules_python//python/extensions:pip.bzl` extension.
For more documentation, see the Bzlmod examples under the
{gh-path}`examples` folder or the documentation for the
{obj}`@rules_python//python/extensions:pip.bzl` extension.

## Dependency sources and exposed hub packages

By default, every package in {attr}`pip.parse.requirements_lock` gets a public
hub alias, such as `@my_deps//foo`. If you want only direct dependencies
available to user code, set {attr}`pip.parse.srcs` to one or more requirement
source files that list those direct packages:

```starlark
pip.parse(
hub_name = "my_deps",
python_version = "3.13",
requirements_lock = "//:requirements_lock.txt",
srcs = ["//:requirements.in"],
)
```

Packages in the lock file that are not listed in the restricted requirement
files still get generated wheel repositories, so direct dependencies can use
their transitive dependencies. Their hub aliases are visible only to the
generated wheel repositories and are not public targets for user code.

When {attr}`pip.parse.requirements_lock` is set, the same `srcs` are also
passed to a generated `@my_deps//:lock.update` target. Running that target
updates the source-tree copy of the lock file, similar to repinning a generated
dependency repository:

```console
bazel run @my_deps//:lock.update
```

The generated target uses the experimental {obj}`lock` rule, so configure the
`uv` toolchain extension before running it.

If a hub has multiple `pip.parse` calls with `srcs`, versioned targets such as
`@my_deps//:lock_313.update` are generated instead.

:::note}
We are using a host-platform compatible toolchain by default to setup pip dependencies.
Expand Down
1 change: 1 addition & 0 deletions python/private/pypi/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ bzl_library(
":attrs_bzl",
":evaluate_markers_bzl",
":parse_requirements_bzl",
":parse_requirements_txt_bzl",
":pep508_env_bzl",
":pep508_evaluate_bzl",
":python_tag_bzl",
Expand Down
34 changes: 34 additions & 0 deletions python/private/pypi/extension.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ You cannot use both the additive_build_content and additive_build_content_file a
hub_group_map = {}
exposed_packages = {}
extra_aliases = {}
lock_targets = {}
whl_libraries = {}
for hub in pip_hub_map.values():
out = hub.build()
Expand All @@ -404,6 +405,7 @@ You cannot use both the additive_build_content and additive_build_content_file a
extra_aliases[hub.name] = out.extra_aliases
hub_group_map[hub.name] = out.group_map
hub_whl_map[hub.name] = out.whl_map
lock_targets[hub.name] = out.lock_targets

return struct(
config = config,
Expand All @@ -412,6 +414,7 @@ You cannot use both the additive_build_content and additive_build_content_file a
facts = simpleapi_cache.get_facts(),
hub_group_map = hub_group_map,
hub_whl_map = hub_whl_map,
lock_targets = lock_targets,
whl_libraries = whl_libraries,
whl_mods = whl_mods,
platform_config_settings = {
Expand Down Expand Up @@ -505,6 +508,15 @@ def _pip_impl(module_ctx):
name = hub_name,
repo_name = hub_name,
extra_hub_aliases = mods.extra_aliases.get(hub_name, {}),
lock_targets = [
json.encode({
"name": lock_target.name,
"out": lock_target.out,
"python_version": lock_target.python_version,
"srcs": lock_target.srcs,
})
for lock_target in mods.lock_targets.get(hub_name, [])
],
whl_map = {
key: whl_config_settings_to_json(values)
for key, values in whl_map.items()
Expand Down Expand Up @@ -786,6 +798,28 @@ The Python version the dependencies are targetting, in Major.Minor format
If an interpreter isn't explicitly provided (using `python_interpreter` or
`python_interpreter_target`), then the version specified here must have
a corresponding `python.toolchain()` configured.
""",
),
"srcs": attr.label_list(
allow_files = True,
doc = """
A list of source files that express the direct requirements for this hub, such
as `requirements.in` files. Packages parsed from these files are exposed as
public hub targets. Packages in the lock files that are not listed here still
get wheel repositories so they can be used as transitive dependencies, but
their hub aliases are only visible to repositories generated by this
`pip.parse` hub.

This is useful when your lock file contains transitive dependencies that should
remain implementation details of your direct dependencies.

When {attr}`pip.parse.requirements_lock` is set, the generated hub also creates
a `lock.update` target that passes these files as `srcs` to the lock rule. This
keeps the source-file API ready for additional dependency declaration formats,
such as `pyproject.toml`.

:::{versionadded} VERSION_NEXT_FEATURE
:::
""",
),
"simpleapi_skip": attr.string_list(
Expand Down
108 changes: 103 additions & 5 deletions python/private/pypi/hub_builder.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ load("//python/private:version_label.bzl", "version_label")
load(":attrs.bzl", "use_isolated")
load(":evaluate_markers.bzl", "evaluate_markers")
load(":parse_requirements.bzl", "parse_requirements")
load(":parse_requirements_txt.bzl", "parse_requirements_txt")
load(":pep508_env.bzl", "env")
load(":pep508_evaluate.bzl", "evaluate")
load(":python_tag.bzl", "python_tag")
Expand Down Expand Up @@ -77,6 +78,9 @@ def hub_builder(
# setting originated from.
# dict[str whl_name, dict[str config_setting, str repo_name]]
_whl_map = {}, # modified by _add_whl_library
# Lock update targets to generate in the hub repository.
# list[struct(out=str, python_version=str, srcs=list[str])]
_lock_targets = [],

# Internal

Expand Down Expand Up @@ -115,6 +119,7 @@ def _build(self):
group_map = {},
extra_aliases = {},
exposed_packages = [],
lock_targets = [],
whl_libraries = {},
)
if self._logger.failed():
Expand All @@ -141,6 +146,9 @@ def _build(self):
# The list of exposed packages in the hub.
# list[str]
exposed_packages = sorted(self._exposed_packages),
# The lock update targets in the hub.
# list[struct(name=str, out=str, python_version=str, srcs=list[str])]
lock_targets = _named_lock_targets(self._lock_targets),

# Mapping of whl_library repo names and their kwargs.
# dict[str repo_name, dict[str, object] kwargs]
Expand Down Expand Up @@ -190,6 +198,7 @@ def _pip_parse(self, module_ctx, pip_attr):
)
_add_group_map(self, pip_attr.experimental_requirement_cycles)
_add_extra_aliases(self, pip_attr.extra_hub_aliases)
_add_lock_target(self, pip_attr)
_create_whl_repos(
self,
module_ctx,
Expand Down Expand Up @@ -347,8 +356,96 @@ def _add_whl_library(self, *, python_version, whl, repo):
else:
mapping[repo.config_setting] = repo_name

def _add_lock_target(self, pip_attr):
"""Adds a generated hub lock update target when the parse attrs are complete.

Args:
self: implicitly added.
pip_attr: The `pip.parse` tag attributes.
"""
if not pip_attr.srcs or not pip_attr.requirements_lock:
return

self._lock_targets.append(struct(
out = _source_file_path(pip_attr.requirements_lock),
python_version = pip_attr.python_version,
srcs = [str(src) for src in pip_attr.srcs],
))

### end of setters, below we have various functions to implement the public methods

def _named_lock_targets(lock_targets):
if len(lock_targets) == 1:
target = lock_targets[0]
return [struct(
name = "lock",
out = target.out,
python_version = target.python_version,
srcs = target.srcs,
)]

return [
struct(
name = "lock_{}".format(version_label(target.python_version)),
out = target.out,
python_version = target.python_version,
srcs = target.srcs,
)
for target in sorted(lock_targets, key = lambda target: target.python_version)
]

def _source_file_path(label):
if type(label) == type(""):
return label

if label.package:
return "{}/{}".format(label.package, label.name)
else:
return label.name

def _parse_dep_srcs(ctx, dep_srcs):
"""Parse dependency source files into normalized package names.

Args:
ctx: A context that has .read function that would read contents from a label.
dep_srcs: List of files that express direct dependencies.

Returns:
dict[str, None] or None: The normalized packages present in the source
files, or None when there are no source files.
"""
if not dep_srcs:
return None

exposed = {}
for file in dep_srcs:
parse_result = parse_requirements_txt(ctx.read(file))
for distribution, _ in parse_result.requirements:
exposed[normalize_name(distribution)] = None

return exposed

def _exposed_packages(ctx, *, pip_attr, whls, logger):
"""Returns hub packages exposed by platform support and direct dependency srcs."""
dep_srcs = _parse_dep_srcs(ctx, pip_attr.srcs)
exposed = {}
for whl in whls:
if not whl.is_exposed:
continue

# pip.parse srcs describe the direct dependencies for this parse tag.
# Platform-specific lock files have already been collapsed into `whls`.
if dep_srcs != None and whl.name not in dep_srcs:
logger.trace(lambda: (
"Package '{}' will not be exposed because it is not present " +
"in srcs"
).format(whl.name))
continue

exposed[whl.name] = None

return exposed

def _set_get_index_urls(self, pip_attr):
default_index_url = pip_attr.experimental_index_url or self._config.index_url
default_extra_index_urls = pip_attr.experimental_extra_index_urls or []
Expand Down Expand Up @@ -513,11 +610,12 @@ def _create_whl_repos(
logger = logger,
)

_add_exposed_packages(self, {
whl.name: None
for whl in requirements_by_platform
if whl.is_exposed
})
_add_exposed_packages(self, _exposed_packages(
module_ctx,
pip_attr = pip_attr,
whls = requirements_by_platform,
logger = logger,
))

whl_modifications = {}
if pip_attr.whl_modifications != None:
Expand Down
57 changes: 52 additions & 5 deletions python/private/pypi/hub_repository.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,36 @@ load(":render_pkg_aliases.bzl", "render_multiplatform_pkg_aliases")
load(":whl_config_setting.bzl", "whl_config_setting")

_BUILD_FILE_CONTENTS = """\
package(default_visibility = ["//visibility:public"])
{loads}package(default_visibility = ["//visibility:public"])

# Ensure the `requirements.bzl` source can be accessed by stardoc, since users load() from it
# Ensure the `requirements.bzl` source can be accessed by stardoc, since users
# load() from it.
exports_files(["requirements.bzl"])
{lock_targets}"""

_LOCK_LOAD = """\
load("@rules_python//python/uv:lock.bzl", "lock")

"""

_LOCK_TARGET = """
lock(
name = {name},
out = {out},
python_version = {python_version},
srcs = {srcs},
visibility = ["//visibility:public"],
)
"""

def _impl(rctx):
bzl_packages = rctx.attr.packages or rctx.attr.whl_map.keys()
bzl_packages = rctx.attr.packages
aliases = render_multiplatform_pkg_aliases(
aliases = {
key: _whl_config_settings_from_json(values)
for key, values in rctx.attr.whl_map.items()
},
exposed_packages = bzl_packages,
extra_hub_aliases = rctx.attr.extra_hub_aliases,
requirement_cycles = rctx.attr.groups,
platform_config_settings = rctx.attr.platform_config_settings,
Expand All @@ -45,7 +62,12 @@ def _impl(rctx):
# `requirement`, et al. macros.
macro_tmpl = "@@{name}//{{}}:{{}}".format(name = rctx.attr.name)

rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS)
rctx.file("BUILD.bazel", render_hub_build_file(
lock_targets = [
json.decode(target)
for target in rctx.attr.lock_targets
],
))
rctx.template(
"config.bzl",
rctx.attr._config_template,
Expand Down Expand Up @@ -78,10 +100,14 @@ hub_repository = repository_rule(
"groups": attr.string_list_dict(
mandatory = False,
),
"lock_targets": attr.string_list(
doc = "JSON-encoded lock targets to render into the hub repository.",
),
"packages": attr.string_list(
mandatory = False,
doc = """\
The list of packages that will be exposed via all_*requirements macros. Defaults to whl_map keys.
The list of packages that will be exposed via public hub aliases and
all_*requirements macros.
""",
),
"platform_config_settings": attr.string_list_dict(
Expand Down Expand Up @@ -110,6 +136,27 @@ in the pip.parse tag class.
implementation = _impl,
)

def render_hub_build_file(*, lock_targets = []):
rendered_lock_targets = _render_lock_targets(lock_targets)
return _BUILD_FILE_CONTENTS.format(
loads = _LOCK_LOAD if rendered_lock_targets else "",
lock_targets = rendered_lock_targets,
)

def _render_lock_targets(lock_targets):
if not lock_targets:
return ""

return "\n" + "\n\n".join([
_LOCK_TARGET.format(
name = repr(target["name"]),
out = repr(target["out"]),
python_version = repr(target["python_version"]),
srcs = render.list(target["srcs"]),
).strip()
for target in lock_targets
]) + "\n"

def _whl_config_settings_from_json(repo_mapping_json):
"""Deserialize the serialized values with whl_config_settings_to_json.

Expand Down
Loading