diff --git a/docs/api/py.md b/docs/api/py.md
index f8fbde999..3907380f5 100644
--- a/docs/api/py.md
+++ b/docs/api/py.md
@@ -158,7 +158,7 @@ Must not be testonly. `py_image_layer` transitions the `//py:layer_tier` flag to
load("@aspect_rules_py//py:defs.bzl", "py_library")
-py_library(name, deps, srcs, data, imports, resolutions, virtual_deps)
+py_library(name, deps, srcs, data, dep_group, imports, resolutions, virtual_deps)
@@ -172,6 +172,7 @@ py_library(name, deps<
| deps | Targets that produce Python code, commonly `py_library` rules. | List of labels | optional | `[]` |
| srcs | Python source files. | List of labels | optional | `[]` |
| data | Runtime dependencies of the program.
The transitive closure of the `data` dependencies will be available in the `.runfiles` folder for this binary/test. The program may optionally use the Runfiles lookup library to locate the data files, see https://pypi.org/project/bazel-runfiles/. Data is analyzed in the inherited caller configuration. Put artifacts that must match the terminal's Python environment in `deps`. | List of labels | optional | `[]` |
+| dep_group | Resolve `deps` within the named dependency group of the uv hub.
Only the `deps` edge changes configuration: this library, its `srcs` and `data`, and every consumer stay in the caller's configuration. Listing hub packages in a library with `dep_group` set keeps shared native dependencies out of per-group configurations, unlike `dep_group` on `py_binary` / `py_test`, which transitions the whole subtree. Empty inherits the caller's group. | String | optional | `""` |
| imports | List of import directories to be added to the PYTHONPATH. | List of strings | optional | `[]` |
| resolutions | Satisfy a virtual_dep with a mapping from external package name to the label of an installed package that provides it. See virtual_deps. | Dictionary: String -> Label | optional | `{}` |
| virtual_deps | - | List of strings | optional | `[]` |
diff --git a/docs/uv.md b/docs/uv.md
index cca7517b0..ca59373f6 100644
--- a/docs/uv.md
+++ b/docs/uv.md
@@ -132,6 +132,30 @@ py_binary(
)
```
+`dep_group` on a `py_binary` / `py_test` is an incoming transition: every
+target beneath it, including shared native libraries, is analyzed again per
+group. To keep the group on the hub edge alone, set `dep_group` on the
+`py_library` that lists the hub packages. Only that library's `deps` resolve
+under the group; the library itself, its consumers and their other dependencies
+share the caller's configuration.
+
+```starlark
+py_library(
+ name = "vendored_pip",
+ dep_group = "vendored_say",
+ deps = ["@pypi//cowsay"],
+)
+
+py_binary(
+ name = "say_vendored",
+ srcs = ["__main__.py_"],
+ deps = [
+ ":vendored_pip",
+ "//lib:shared_native", # analyzed once, whatever group the pip deps use
+ ],
+)
+```
+
Targets that need every dependency in one dependency group can use the
group-specific lists generated in `defs.bzl`. The `group_deps()` helper follows
the consuming target's `dep_group`, so the group name is never repeated:
diff --git a/e2e/cases/multi-project-hub/BUILD.bazel b/e2e/cases/multi-project-hub/BUILD.bazel
index 4f12be1a0..2ae8c1a0e 100644
--- a/e2e/cases/multi-project-hub/BUILD.bazel
+++ b/e2e/cases/multi-project-hub/BUILD.bazel
@@ -2,7 +2,7 @@
# e2e snapshots. Three sub-projects (alpha, beta, gamma) all depend on
# cowsay, exercising the N>1 select-arm and dep_group config_setting
# emission that the single-project @pypi_single snapshot cannot cover.
-load("@aspect_rules_py//py:defs.bzl", "py_test")
+load("@aspect_rules_py//py:defs.bzl", "py_library", "py_test")
# Alpha defines `[dependency-groups]` (`dev` + hyphenated `unit-tests`)
# instead of an implicit project-name group, so the snapshot covers both
@@ -50,3 +50,19 @@ py_test(
"@pypi_multi//cowsay",
],
)
+
+# `dep_group` on a py_library scopes the group to that library's deps; the
+# consuming test sets no group and stays in the caller's configuration.
+py_library(
+ name = "cowsay_beta",
+ dep_group = "beta",
+ deps = ["@pypi_multi//cowsay"],
+)
+
+py_test(
+ name = "beta_scoped",
+ srcs = ["__test__.py"],
+ main = "__test__.py",
+ python_version = "3.11",
+ deps = [":cowsay_beta"],
+)
diff --git a/e2e/rules-python-interop/reset-data-edges/BUILD.bazel b/e2e/rules-python-interop/reset-data-edges/BUILD.bazel
index 300fcabc5..3b86f249e 100644
--- a/e2e/rules-python-interop/reset-data-edges/BUILD.bazel
+++ b/e2e/rules-python-interop/reset-data-edges/BUILD.bazel
@@ -170,4 +170,23 @@ root(
],
)
+# py_library's dep_group reaches only its deps edge: the library and its data
+# stay in the caller's group.
+py_library(
+ name = "scoped_library",
+ data = [":probe"],
+ dep_group = "scoped",
+ tags = ["manual"],
+ deps = [":probe"],
+)
+
+root(
+ name = "scoped_root",
+ tags = ["manual"],
+ deps = [
+ ":probe",
+ ":scoped_library",
+ ],
+)
+
reset_data_edges_test_suite()
diff --git a/e2e/rules-python-interop/reset-data-edges/tests.bzl b/e2e/rules-python-interop/reset-data-edges/tests.bzl
index 8e33a6ce4..9ebfe729e 100644
--- a/e2e/rules-python-interop/reset-data-edges/tests.bzl
+++ b/e2e/rules-python-interop/reset-data-edges/tests.bzl
@@ -12,14 +12,23 @@ _FREETHREADED_FLAG = "@aspect_rules_py//py/private/interpreter:freethreaded"
_RPY_FREETHREADED_FLAG = "@rules_python//python/config_settings:py_freethreaded"
_ProbeInfo = provider(fields = ["file"])
-_ProbeFilesInfo = provider(fields = ["files", "modes", "bin_dirs"])
+_ProbeFilesInfo = provider(fields = ["files", "modes", "bin_dirs", "groups"])
def _probe_impl(ctx):
out = ctx.actions.declare_file(ctx.label.name + ".txt")
ctx.actions.write(out, "probe")
- return [_ProbeInfo(
- file = out,
- )]
+ return [
+ _ProbeInfo(
+ file = out,
+ ),
+ # Lets py_library list the probe in `deps`.
+ PyInfo(
+ imports = depset(),
+ transitive_sources = depset(),
+ virtual_dependencies = depset(),
+ virtual_resolutions = depset(),
+ ),
+ ]
probe = rule(
implementation = _probe_impl,
@@ -35,6 +44,7 @@ def _probe_aspect_impl(target, ctx):
transitive = []
transitive_modes = []
transitive_bin_dirs = []
+ transitive_groups = []
deps = []
for attr_name in ["data", "deps"]:
deps.extend(getattr(ctx.rule.attr, attr_name, []))
@@ -49,6 +59,7 @@ def _probe_aspect_impl(target, ctx):
transitive.append(dep[_ProbeFilesInfo].files)
transitive_modes.append(dep[_ProbeFilesInfo].modes)
transitive_bin_dirs.append(dep[_ProbeFilesInfo].bin_dirs)
+ transitive_groups.append(dep[_ProbeFilesInfo].groups)
# Record every visited target; the test impl filters to the names it
# asserts on, keeping the fixture-name coupling in one place.
@@ -61,16 +72,19 @@ def _probe_aspect_impl(target, ctx):
# bin_dir carries the configuration's output segment, so equal paths mean
# one configuration.
bin_dirs = [(ctx.label.name, ctx.bin_dir.path)]
+ groups = [(ctx.label.name, ctx.attr._dep_group[BuildSettingInfo].value)]
return [_ProbeFilesInfo(
files = depset(direct = direct, transitive = transitive),
modes = depset(direct = modes, transitive = transitive_modes),
bin_dirs = depset(direct = bin_dirs, transitive = transitive_bin_dirs),
+ groups = depset(direct = groups, transitive = transitive_groups),
)]
_probe_aspect = aspect(
implementation = _probe_aspect_impl,
attr_aspects = ["data", "deps", "venv"],
attrs = {
+ "_dep_group": attr.label(default = _DEP_GROUP_FLAG),
"_freethreaded": attr.label(default = _FREETHREADED_FLAG),
"_rpy_freethreaded": attr.label(default = _RPY_FREETHREADED_FLAG),
},
@@ -113,6 +127,7 @@ def _root_impl(ctx):
files = depset(transitive = transitive),
modes = depset(transitive = [dep[_ProbeFilesInfo].modes for dep in ctx.attr.deps]),
bin_dirs = depset(transitive = [dep[_ProbeFilesInfo].bin_dirs for dep in ctx.attr.deps]),
+ groups = depset(transitive = [dep[_ProbeFilesInfo].groups for dep in ctx.attr.deps]),
)]
def _baseline_transition_impl(settings, attr):
@@ -234,6 +249,27 @@ def _shared_binaries_test_impl(ctx):
_shared_binaries_test = analysistest.make(_shared_binaries_test_impl)
+def _scoped_library_test_impl(ctx):
+ env = analysistest.begin(ctx)
+ under_test = analysistest.target_under_test(env)[_ProbeFilesInfo]
+ asserts.equals(
+ env,
+ 2,
+ len(under_test.files.to_list()),
+ "the probe should analyze once in the caller's group and once under the library's deps",
+ )
+ expected = [
+ ("probe", "baseline"),
+ ("probe", "scoped"),
+ ("scoped_library", "baseline"),
+ ]
+ tracked = {name: True for name, _ in expected}
+ groups = [group for group in under_test.groups.to_list() if group[0] in tracked]
+ asserts.equals(env, sorted(expected), sorted(groups))
+ return analysistest.end(env)
+
+_scoped_library_test = analysistest.make(_scoped_library_test_impl)
+
def reset_data_edges_test_suite():
_reset_data_edges_test(
name = "reset_data_edges_test",
@@ -250,3 +286,7 @@ def reset_data_edges_test_suite():
tags = ["manual"],
target_under_test = ":binaries_root",
)
+ _scoped_library_test(
+ name = "scoped_library_test",
+ target_under_test = ":scoped_root",
+ )
diff --git a/py/private/py_library.bzl b/py/private/py_library.bzl
index f56ec7f4c..5f3c7fd20 100644
--- a/py/private/py_library.bzl
+++ b/py/private/py_library.bzl
@@ -11,7 +11,7 @@ load("//py/private:providers.bzl", "PyWheelsInfo")
load("//py/private:pth.bzl", "make_imports_depset")
load("//py/private:py_info.bzl", "PyInfo")
load("//py/private:py_info_interop.bzl", "RulesPythonPyInfo", "get_py_info", "has_py_info")
-load("//py/private:transitions.bzl", "reset_python_flags_transition")
+load("//py/private:transitions.bzl", "dep_group_transition", "reset_python_flags_transition")
def _make_instrumented_files_info(ctx):
return coverage_common.instrumented_files_info(
@@ -189,12 +189,8 @@ def _py_library_impl(ctx):
return providers
-_attrs = dict({
- "srcs": attr.label_list(
- doc = "Python source files.",
- allow_files = True,
- ),
- "deps": attr.label_list(
+def _deps_attr(**kwargs):
+ return attr.label_list(
doc = "Targets that produce Python code, commonly `py_library` rules.",
# This attribute — shared by py_library, py_binary and py_test — is the
# public surface that supports rules_python interop: a dep may carry
@@ -203,7 +199,15 @@ _attrs = dict({
# rules_py emits @rules_python providers only under the
# migration-only //py:emit_rules_python_providers flag.
providers = [[PyInfo], [RulesPythonPyInfo], [CcInfo]],
+ **kwargs
+ )
+
+_attrs = dict({
+ "srcs": attr.label_list(
+ doc = "Python source files.",
+ allow_files = True,
),
+ "deps": _deps_attr(),
"data": attr.label_list(
doc = """Runtime dependencies of the program.
@@ -247,9 +251,25 @@ py_library_utils = struct(
py_library = rule(
implementation = py_library_utils.implementation,
- attrs = dict({
- "virtual_deps": attr.string_list(allow_empty = True, default = []),
- "_emit_rules_python_providers": attr.label(default = "//py/private:emit_rules_python_providers"),
- }, **py_library_utils.attrs),
+ attrs = dict(
+ py_library_utils.attrs,
+ deps = _deps_attr(cfg = dep_group_transition),
+ dep_group = attr.string(
+ default = "",
+ doc = """Resolve `deps` within the named dependency group of the uv hub.
+
+Only the `deps` edge changes configuration: this library, its `srcs` and `data`,
+and every consumer stay in the caller's configuration. Listing hub packages in a
+library with `dep_group` set keeps shared native dependencies out of per-group
+configurations, unlike `dep_group` on `py_binary` / `py_test`, which transitions
+the whole subtree. Empty inherits the caller's group.
+""",
+ ),
+ virtual_deps = attr.string_list(allow_empty = True, default = []),
+ _allowlist_function_transition = attr.label(
+ default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+ ),
+ _emit_rules_python_providers = attr.label(default = "//py/private:emit_rules_python_providers"),
+ ),
provides = py_library_utils.py_library_providers,
)
diff --git a/py/private/transitions.bzl b/py/private/transitions.bzl
index 27269764f..82e6bbfff 100644
--- a/py/private/transitions.bzl
+++ b/py/private/transitions.bzl
@@ -95,6 +95,22 @@ python_transition = transition(
outputs = _ALL_FLAGS,
)
+# The `py_library.deps` edge. Resolving hub packages under a dependency group
+# here keeps the library, its consumers and their non-Python dependencies in
+# the caller's configuration.
+def _dep_group_transition_impl(settings, attr):
+ dep_group = attr.dep_group or settings[_DEP_GROUP_FLAG]
+ return {
+ _DEP_GROUP_FLAG: dep_group,
+ _DEP_GROUP_BASELINE_FLAG: _capture_baseline(settings, _DEP_GROUP_FLAG, _DEP_GROUP_BASELINE_FLAG, dep_group),
+ }
+
+dep_group_transition = transition(
+ implementation = _dep_group_transition_impl,
+ inputs = [_DEP_GROUP_FLAG, _DEP_GROUP_BASELINE_FLAG],
+ outputs = [_DEP_GROUP_FLAG, _DEP_GROUP_BASELINE_FLAG],
+)
+
# The launcher -> venv edge. Validation never runs here: the venv's own rule
# transition always applies next, may override either half of a version/GIL
# combination, and is the sole authority for rejecting the final configuration.