Skip to content

Commit 37cb91a

Browse files
feat: allow registering arbitrary settings for py_binary transitions (#3248)
This implements the ability for users to add additional settings that py_binary, py_test, and py_wheel can transition on. There were three main use cases motivating this feature: 1. Making it easier to have multiple pypi dependency closures and shared dependencies. 2. Making it easier to override flags for `py_wheel`. 3. Making it easier to have per-target setting of things like bootstrap_impl, venv site packages, etc. It also adds most of our config settings to the the transition inputs/outputs for those rules, which allows users to per-target force particular settings without having to use e.g. `with_cfg` to wrap a target with the desired transition settings. It also lets use avoid adding dozens of attributes (one per setting); today there are about 17 flags. Under the hood, this works by having a bzlmod api that users can pass labels to. These labels are put into a generated bzl file, which the rules load and add to their list of transition inputs/outputs. On the target level, the `config_settings` attribute, which is a `dict[label, str]`, can be set to change the particular flags of interest. Along the way... * Create a common_labels.bzl file for the shared label strings * Remove the defunct py_reconfig code in sh_py_run_test. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 6df5cbb commit 37cb91a

35 files changed

+638
-136
lines changed

MODULE.bazel

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ bazel_dep(name = "platforms", version = "0.0.11")
1313
# Use py_proto_library directly from protobuf repository
1414
bazel_dep(name = "protobuf", version = "29.0-rc2", repo_name = "com_google_protobuf")
1515

16-
internal_deps = use_extension("//python/private:internal_deps.bzl", "internal_deps")
16+
rules_python_config = use_extension("//python/extensions:config.bzl", "config")
1717
use_repo(
18-
internal_deps,
18+
rules_python_config,
1919
"pypi__build",
2020
"pypi__click",
2121
"pypi__colorama",
@@ -218,6 +218,19 @@ use_repo(
218218
"whl_with_build_files",
219219
)
220220

221+
dev_rules_python_config = use_extension(
222+
"//python/extensions:config.bzl",
223+
"config",
224+
dev_dependency = True,
225+
)
226+
dev_rules_python_config.add_transition_setting(
227+
# Intentionally add a setting already present for testing
228+
setting = "//python/config_settings:python_version",
229+
)
230+
dev_rules_python_config.add_transition_setting(
231+
setting = "//tests/multi_pypi:external_deps_name",
232+
)
233+
221234
# Add gazelle plugin so that we can run the gazelle example as an e2e integration
222235
# test and include the distribution files.
223236
local_path_override(
@@ -291,7 +304,17 @@ dev_pip.parse(
291304
python_version = "3.11",
292305
requirements_lock = "//examples/wheel:requirements_server.txt",
293306
)
294-
use_repo(dev_pip, "dev_pip", "pypiserver")
307+
dev_pip.parse(
308+
hub_name = "pypi_alpha",
309+
python_version = "3.11",
310+
requirements_lock = "//tests/multi_pypi/alpha:requirements.txt",
311+
)
312+
dev_pip.parse(
313+
hub_name = "pypi_beta",
314+
python_version = "3.11",
315+
requirements_lock = "//tests/multi_pypi/beta:requirements.txt",
316+
)
317+
use_repo(dev_pip, "dev_pip", "pypi_alpha", "pypi_beta", "pypiserver")
295318

296319
# Bazel integration test setup below
297320

WORKSPACE

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ load("//:internal_dev_setup.bzl", "rules_python_internal_setup")
6969
rules_python_internal_setup()
7070

7171
load("@pythons_hub//:versions.bzl", "PYTHON_VERSIONS")
72-
load("//python:repositories.bzl", "python_register_multi_toolchains")
72+
load("//python:repositories.bzl", "py_repositories", "python_register_multi_toolchains")
73+
74+
py_repositories()
7375

7476
python_register_multi_toolchains(
7577
name = "python",
@@ -155,3 +157,26 @@ pip_parse(
155157
load("@dev_pip//:requirements.bzl", docs_install_deps = "install_deps")
156158

157159
docs_install_deps()
160+
161+
#####################
162+
# Pypi repos for //tests/multi_pypi
163+
164+
pip_parse(
165+
name = "pypi_alpha",
166+
python_interpreter_target = interpreter,
167+
requirements_lock = "//tests/multi_pypi/alpha:requirements.txt",
168+
)
169+
170+
load("@pypi_alpha//:requirements.bzl", pypi_alpha_install_deps = "install_deps")
171+
172+
pypi_alpha_install_deps()
173+
174+
pip_parse(
175+
name = "pypi_beta",
176+
python_interpreter_target = interpreter,
177+
requirements_lock = "//tests/multi_pypi/beta:requirements.txt",
178+
)
179+
180+
load("@pypi_beta//:requirements.bzl", pypi_beta_install_deps = "install_deps")
181+
182+
pypi_beta_install_deps()
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# How to use a common set of dependencies with multiple PyPI versions
2+
3+
In this guide, we show how to handle a situation common to monorepos
4+
that extensively share code: How does a common library refer to the correct
5+
`@pypi_<name>` hub when binaries may have their own requirements (and thus
6+
PyPI hub name)? Stated as code, this situation:
7+
8+
```bzl
9+
10+
py_binary(
11+
name = "bin_alpha",
12+
deps = ["@pypi_alpha//requests", ":common"],
13+
)
14+
py_binary(
15+
name = "bin_beta",
16+
deps = ["@pypi_beta//requests", ":common"],
17+
)
18+
19+
py_library(
20+
name = "common",
21+
deps = ["@pypi_???//more_itertools"] # <-- Which @pypi repo?
22+
)
23+
```
24+
25+
## Using flags to pick a hub
26+
27+
The basic trick to make `:common` pick the appropriate `@pypi_<name>` is to use
28+
`select()` to choose one based on build flags. To help this process, `py_binary`
29+
et al allow forcing particular build flags to be used, and custom flags can be
30+
registered to allow `py_binary` et al to set them.
31+
32+
In this example, we create a custom string flag named `//:pypi_hub`,
33+
register it to allow using it with `py_binary` directly, then use `select()`
34+
to pick different dependencies.
35+
36+
```bzl
37+
# File: MODULE.bazel
38+
39+
rules_python_config.add_transition_setting(
40+
setting = "//:pypi_hub",
41+
)
42+
43+
# File: BUILD.bazel
44+
45+
```bzl
46+
47+
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
48+
49+
string_flag(
50+
name = "pypi_hub",
51+
)
52+
53+
config_setting(
54+
name = "is_pypi_alpha",
55+
flag_values = {"//:pypi_hub": "alpha"},
56+
)
57+
58+
config_setting(
59+
name = "is_pypi_beta",
60+
flag_values = {"//:pypi_hub": "beta"}
61+
)
62+
63+
py_binary(
64+
name = "bin_alpha",
65+
srcs = ["bin_alpha.py"],
66+
config_settings = {
67+
"//:pypi_hub": "alpha",
68+
},
69+
deps = ["@pypi_alpha//requests", ":common"],
70+
)
71+
py_binary(
72+
name = "bin_beta",
73+
srcs = ["bin_beta.py"],
74+
config_settings = {
75+
"//:pypi_hub": "beta",
76+
},
77+
deps = ["@pypi_beta//requests", ":common"],
78+
)
79+
py_library(
80+
name = "common",
81+
deps = select({
82+
":is_pypi_alpha": ["@pypi_alpha//more_itertools"],
83+
":is_pypi_beta": ["@pypi_beta//more_itertools"],
84+
}),
85+
)
86+
```
87+
88+
When `bin_alpha` and `bin_beta` are built, they will have the `pypi_hub`
89+
flag force to their respective value. When `:common` is evaluated, it sees
90+
the flag value of the binary that is consuming it, and the `select()` resolves
91+
appropriately.

internal_dev_deps.bzl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ def rules_python_internal_deps():
4141
For dependencies needed by *users* of rules_python, see
4242
python/private/py_repositories.bzl.
4343
"""
44-
internal_config_repo(name = "rules_python_internal")
44+
internal_config_repo(
45+
name = "rules_python_internal",
46+
transition_settings = [
47+
str(Label("//tests/multi_pypi:external_deps_name")),
48+
],
49+
)
4550

4651
local_repository(
4752
name = "other",

python/extensions/BUILD.bazel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,12 @@ bzl_library(
3939
"//python/private:python_bzl",
4040
],
4141
)
42+
43+
bzl_library(
44+
name = "config_bzl",
45+
srcs = ["config.bzl"],
46+
visibility = ["//:__subpackages__"],
47+
deps = [
48+
"//python/private:internal_config_repo_bzl",
49+
],
50+
)

python/extensions/config.bzl

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Extension for configuring global settings of rules_python."""
2+
3+
load("//python/private:internal_config_repo.bzl", "internal_config_repo")
4+
load("//python/private/pypi:deps.bzl", "pypi_deps")
5+
6+
_add_transition_setting = tag_class(
7+
doc = """
8+
Specify a build setting that terminal rules transition on by default.
9+
10+
Terminal rules are rules such as py_binary, py_test, py_wheel, or similar
11+
rules that represent some deployable unit. Settings added here can
12+
then be used a keys with the {obj}`config_settings` attribute.
13+
14+
:::{note}
15+
This adds the label as a dependency of the Python rules. Take care to not refer
16+
to repositories that are expensive to create or invalidate frequently.
17+
:::
18+
""",
19+
attrs = {
20+
"setting": attr.label(doc = "The build setting to add."),
21+
},
22+
)
23+
24+
def _config_impl(mctx):
25+
transition_setting_generators = {}
26+
transition_settings = []
27+
for mod in mctx.modules:
28+
for tag in mod.tags.add_transition_setting:
29+
setting = str(tag.setting)
30+
if setting not in transition_setting_generators:
31+
transition_setting_generators[setting] = []
32+
transition_settings.append(setting)
33+
transition_setting_generators[setting].append(mod.name)
34+
35+
internal_config_repo(
36+
name = "rules_python_internal",
37+
transition_setting_generators = transition_setting_generators,
38+
transition_settings = transition_settings,
39+
)
40+
41+
pypi_deps()
42+
43+
config = module_extension(
44+
doc = """Global settings for rules_python.
45+
46+
:::{versionadded} VERSION_NEXT_FEATURE
47+
:::
48+
""",
49+
implementation = _config_impl,
50+
tag_classes = {
51+
"add_transition_setting": _add_transition_setting,
52+
},
53+
)

python/private/BUILD.bazel

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ bzl_library(
106106
name = "builders_util_bzl",
107107
srcs = ["builders_util.bzl"],
108108
deps = [
109+
":bzlmod_enabled_bzl",
109110
"@bazel_skylib//lib:types",
110111
],
111112
)
@@ -135,6 +136,11 @@ bzl_library(
135136
],
136137
)
137138

139+
bzl_library(
140+
name = "common_labels_bzl",
141+
srcs = ["common_labels.bzl"],
142+
)
143+
138144
bzl_library(
139145
name = "config_settings_bzl",
140146
srcs = ["config_settings.bzl"],
@@ -408,6 +414,7 @@ bzl_library(
408414
":py_runtime_info_bzl",
409415
":rules_cc_srcs_bzl",
410416
":toolchain_types_bzl",
417+
":transition_labels_bzl",
411418
"@bazel_skylib//lib:dicts",
412419
"@bazel_skylib//lib:paths",
413420
"@bazel_skylib//lib:structs",
@@ -583,6 +590,7 @@ bzl_library(
583590
deps = [
584591
":py_package_bzl",
585592
":stamp_bzl",
593+
":transition_labels_bzl",
586594
],
587595
)
588596

@@ -649,6 +657,16 @@ bzl_library(
649657
srcs = ["toolchain_types.bzl"],
650658
)
651659

660+
bzl_library(
661+
name = "transition_labels_bzl",
662+
srcs = ["transition_labels.bzl"],
663+
deps = [
664+
"common_labels_bzl",
665+
"@bazel_skylib//lib:collections",
666+
"@rules_python_internal//:extra_transition_settings_bzl",
667+
],
668+
)
669+
652670
bzl_library(
653671
name = "util_bzl",
654672
srcs = ["util.bzl"],

python/private/attr_builders.bzl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ load(
3131
"kwargs_setter",
3232
"kwargs_setter_doc",
3333
"kwargs_setter_mandatory",
34+
"normalize_transition_in_out_values",
3435
"to_label_maybe",
3536
)
3637

@@ -167,6 +168,8 @@ def _AttrCfg_new(
167168
}
168169
kwargs_set_default_list(state, _INPUTS)
169170
kwargs_set_default_list(state, _OUTPUTS)
171+
normalize_transition_in_out_values("input", state[_INPUTS])
172+
normalize_transition_in_out_values("output", state[_OUTPUTS])
170173

171174
# buildifier: disable=uninitialized
172175
self = struct(

python/private/attributes.bzl

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,58 @@ COVERAGE_ATTRS = {
405405
# Attributes specific to Python executable-equivalent rules. Such rules may not
406406
# accept Python sources (e.g. some packaged-version of a py_test/py_binary), but
407407
# still accept Python source-agnostic settings.
408+
CONFIG_SETTINGS_ATTR = {
409+
"config_settings": lambda: attrb.LabelKeyedStringDict(
410+
doc = """
411+
Config settings to change for this target.
412+
413+
The keys are labels for settings, and the values are strings for the new value
414+
to use. Pass `Label` objects or canonical label strings for the keys to ensure
415+
they resolve as expected (canonical labels start with `@@` and can be
416+
obtained by calling `str(Label(...))`).
417+
418+
Most `@rules_python//python/config_setting` settings can be used here, which
419+
allows, for example, making only a certain `py_binary` use
420+
{obj}`--boostrap_impl=script`.
421+
422+
Additional or custom config settings can be registered using the
423+
{obj}`add_transition_setting` API. This allows, for example, forcing a
424+
particular CPU, or defining a custom setting that `select()` uses elsewhere
425+
to pick between `pip.parse` hubs. See the [How to guide on multiple
426+
versions of a library] for a more concrete example.
427+
428+
:::{note}
429+
These values are transitioned on, so will affect the analysis graph and the
430+
associated memory overhead. The more unique configurations in your overall
431+
build, the more memory and (often unnecessary) re-analysis and re-building
432+
can occur. See
433+
https://bazel.build/extending/config#memory-performance-considerations for
434+
more information about risks and considerations.
435+
:::
436+
437+
:::{versionadded} VERSION_NEXT_FEATURE
438+
:::
439+
""",
440+
),
441+
}
442+
443+
def apply_config_settings_attr(settings, attr):
444+
"""Applies the config_settings attribute to the settings.
445+
446+
Args:
447+
settings: The settings dict to modify in-place.
448+
attr: The rule attributes struct.
449+
450+
Returns:
451+
{type}`dict[str, object]` the input `settings` value.
452+
"""
453+
for key, value in attr.config_settings.items():
454+
settings[str(key)] = value
455+
return settings
456+
408457
AGNOSTIC_EXECUTABLE_ATTRS = dicts.add(
409458
DATA_ATTRS,
459+
CONFIG_SETTINGS_ATTR,
410460
{
411461
"env": lambda: attrb.StringDict(
412462
doc = """\

0 commit comments

Comments
 (0)