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.