Skip to content

Commit 4ccf5b2

Browse files
authored
feat: allow specifying arbitrary constraints for local toolchains (#2829)
This adds the ability for local toolchains to have arbitrary constraints set on them. This allows accomplishing two goals: 1. Makes it easier to enable/disable them on the command line, instead of having them entirely override an existing config and having to comment/uncomment the MODULE.bazel file sections. 2. Allows configuring them so that the repository is never initialized, which avoids the repository from being initialized during toolchain resolution, even if it will never match because of (1).
1 parent ccbe5dc commit 4ccf5b2

File tree

9 files changed

+278
-14
lines changed

9 files changed

+278
-14
lines changed

.bazelci/presubmit.yml

+2
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ buildifier:
5151
test_flags:
5252
- "--noenable_bzlmod"
5353
- "--enable_workspace"
54+
- "--test_tag_filters=-integration-test"
5455
build_flags:
5556
- "--noenable_bzlmod"
5657
- "--enable_workspace"
58+
- "--build_tag_filters=-integration-test"
5759
bazel: 7.x
5860
.common_bazelinbazel_config: &common_bazelinbazel_config
5961
build_flags:

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ END_UNRELEASED_TEMPLATE
8484
* Repo utilities `execute_unchecked`, `execute_checked`, and `execute_checked_stdout` now
8585
support `log_stdout` and `log_stderr` keyword arg booleans. When these are `True`
8686
(the default), the subprocess's stdout/stderr will be logged.
87+
* (toolchains) Local toolchains can be activated with custom flags. See
88+
[Conditionally using local toolchains] docs for how to configure.
8789

8890
{#v0-0-0-removed}
8991
### Removed

docs/toolchains.md

+69-4
Original file line numberDiff line numberDiff line change
@@ -377,15 +377,14 @@ local_runtime_repo(
377377
local_runtime_toolchains_repo(
378378
name = "local_toolchains",
379379
runtimes = ["local_python3"],
380+
# TIP: The `target_settings` arg can be used to activate them based on
381+
# command line flags; see docs below.
380382
)
381383

382384
# Step 3: Register the toolchains
383385
register_toolchains("@local_toolchains//:all", dev_dependency = True)
384386
```
385387

386-
Note that `register_toolchains` will insert the local toolchain earlier in the
387-
toolchain ordering, so it will take precedence over other registered toolchains.
388-
389388
:::{important}
390389
Be sure to set `dev_dependency = True`. Using a local toolchain only makes sense
391390
for the root module.
@@ -397,6 +396,72 @@ downstream modules.
397396

398397
Multiple runtimes and/or toolchains can be defined, which allows for multiple
399398
Python versions and/or platforms to be configured in a single `MODULE.bazel`.
399+
Note that `register_toolchains` will insert the local toolchain earlier in the
400+
toolchain ordering, so it will take precedence over other registered toolchains.
401+
To better control when the toolchain is used, see [Conditionally using local
402+
toolchains]
403+
404+
### Conditionally using local toolchains
405+
406+
By default, a local toolchain has few constraints and is early in the toolchain
407+
ordering, which means it will usually be used no matter what. This can be
408+
problematic for CI (where it shouldn't be used), expensive for CI (CI must
409+
initialize/download the repository to determine its Python version), and
410+
annoying for iterative development (enabling/disabling it requires modifying
411+
MODULE.bazel).
412+
413+
These behaviors can be mitigated, but it requires additional configuration
414+
to avoid triggering the local toolchain repository to initialize (i.e. run
415+
local commands and perform downloads).
416+
417+
The two settings to change are
418+
{obj}`local_runtime_toolchains_repo.target_compatible_with` and
419+
{obj}`local_runtime_toolchains_repo.target_settings`, which control how Bazel
420+
decides if a toolchain should match. By default, they point to targets *within*
421+
the local runtime repository (trigger repo initialization). We have to override
422+
them to *not* reference the local runtime repository at all.
423+
424+
In the example below, we reconfigure the local toolchains so they are only
425+
activated if the custom flag `--//:py=local` is set and the target platform
426+
matches the Bazel host platform. The net effect is CI won't use the local
427+
toolchain (nor initialize its repository), and developers can easily
428+
enable/disable the local toolchain with a command line flag.
429+
430+
```
431+
# File: MODULE.bazel
432+
bazel_dep(name = "bazel_skylib", version = "1.7.1")
433+
434+
local_runtime_toolchains_repo(
435+
name = "local_toolchains",
436+
runtimes = ["local_python3"],
437+
target_compatible_with = {
438+
"local_python3": ["HOST_CONSTRAINTS"],
439+
},
440+
target_settings = {
441+
"local_python3": ["@//:is_py_local"]
442+
}
443+
)
444+
445+
# File: BUILD.bazel
446+
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
447+
448+
config_setting(
449+
name = "is_py_local",
450+
flag_values = {":py": "local"},
451+
)
452+
453+
string_flag(
454+
name = "py",
455+
build_setting_default = "",
456+
)
457+
```
458+
459+
:::{tip}
460+
Easily switching between *multiple* local toolchains can be accomplished by
461+
adding additional `:is_py_X` targets and setting `--//:py` to match.
462+
to easily switch between different local toolchains.
463+
:::
464+
400465

401466
## Runtime environment toolchain
402467

@@ -425,7 +490,7 @@ locally installed Python.
425490
### Autodetecting toolchain
426491

427492
The autodetecting toolchain is a deprecated toolchain that is built into Bazel.
428-
It's name is a bit misleading: it doesn't autodetect anything. All it does is
493+
**It's name is a bit misleading: it doesn't autodetect anything**. All it does is
429494
use `python3` from the environment a binary runs within. This provides extremely
430495
limited functionality to the rules (at build time, nothing is knowable about
431496
the Python runtime).

python/private/local_runtime_toolchains_repo.bzl

+109
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ define_local_toolchain_suites(
2626
name = "toolchains",
2727
version_aware_repo_names = {version_aware_names},
2828
version_unaware_repo_names = {version_unaware_names},
29+
repo_exec_compatible_with = {repo_exec_compatible_with},
30+
repo_target_compatible_with = {repo_target_compatible_with},
31+
repo_target_settings = {repo_target_settings},
2932
)
3033
"""
3134

@@ -39,6 +42,9 @@ def _local_runtime_toolchains_repo(rctx):
3942

4043
rctx.file("BUILD.bazel", _TOOLCHAIN_TEMPLATE.format(
4144
version_aware_names = render.list(rctx.attr.runtimes),
45+
repo_target_settings = render.string_list_dict(rctx.attr.target_settings),
46+
repo_target_compatible_with = render.string_list_dict(rctx.attr.target_compatible_with),
47+
repo_exec_compatible_with = render.string_list_dict(rctx.attr.exec_compatible_with),
4248
version_unaware_names = render.list(rctx.attr.default_runtimes or rctx.attr.runtimes),
4349
))
4450

@@ -62,8 +68,36 @@ These will be defined as *version-unaware* toolchains. This means they will
6268
match any Python version. As such, they are registered after the version-aware
6369
toolchains defined by the `runtimes` attribute.
6470
71+
If not set, then the `runtimes` values will be used.
72+
6573
Note that order matters: it determines the toolchain priority within the
6674
package.
75+
""",
76+
),
77+
"exec_compatible_with": attr.string_list_dict(
78+
doc = """
79+
Constraints that must be satisfied by an exec platform for a toolchain to be used.
80+
81+
This is a `dict[str, list[str]]`, where the keys are repo names from the
82+
`runtimes` or `default_runtimes` args, and the values are constraint
83+
target labels (e.g. OS, CPU, etc).
84+
85+
:::{note}
86+
Specify `@//foo:bar`, not simply `//foo:bar` or `:bar`. The additional `@` is
87+
needed because the strings are evaluated in a different context than where
88+
they originate.
89+
:::
90+
91+
The list of settings become the {obj}`toolchain.exec_compatible_with` value for
92+
each respective repo.
93+
94+
This allows a local toolchain to only be used if certain exec platform
95+
conditions are met, typically values from `@platforms`.
96+
97+
See the [Local toolchains] docs for examples and further information.
98+
99+
:::{versionadded} VERSION_NEXT_FEATURE
100+
:::
67101
""",
68102
),
69103
"runtimes": attr.string_list(
@@ -76,6 +110,81 @@ are registered before `default_runtimes`.
76110
77111
Note that order matters: it determines the toolchain priority within the
78112
package.
113+
""",
114+
),
115+
"target_compatible_with": attr.string_list_dict(
116+
doc = """
117+
Constraints that must be satisfied for a toolchain to be used.
118+
119+
120+
This is a `dict[str, list[str]]`, where the keys are repo names from the
121+
`runtimes` or `default_runtimes` args, and the values are constraint
122+
target labels (e.g. OS, CPU, etc), or the special string `"HOST_CONSTRAINTS"`
123+
(which will be replaced with the current Bazel hosts's constraints).
124+
125+
If a repo's entry is missing or empty, it defaults to the supported OS the
126+
underlying runtime repository detects as compatible.
127+
128+
:::{note}
129+
Specify `@//foo:bar`, not simply `//foo:bar` or `:bar`. The additional `@` is
130+
needed because the strings are evaluated in a different context than where
131+
they originate.
132+
:::
133+
134+
The list of settings **becomes the** the {obj}`toolchain.target_compatible_with`
135+
value for each respective repo; i.e. they _replace_ the auto-detected values
136+
the local runtime itself computes.
137+
138+
This allows a local toolchain to only be used if certain target platform
139+
conditions are met, typically values from `@platforms`.
140+
141+
See the [Local toolchains] docs for examples and further information.
142+
143+
:::{seealso}
144+
The `target_settings` attribute, which handles `config_setting` values,
145+
instead of constraints.
146+
:::
147+
148+
:::{versionadded} VERSION_NEXT_FEATURE
149+
:::
150+
""",
151+
),
152+
"target_settings": attr.string_list_dict(
153+
doc = """
154+
Config settings that must be satisfied for a toolchain to be used.
155+
156+
This is a `dict[str, list[str]]`, where the keys are repo names from the
157+
`runtimes` or `default_runtimes` args, and the values are {obj}`config_setting()`
158+
target labels.
159+
160+
If a repo's entry is missing or empty, it will default to
161+
`@<repo>//:is_match_python_version` (for repos in `runtimes`) or an empty list
162+
(for repos in `default_runtimes`).
163+
164+
:::{note}
165+
Specify `@//foo:bar`, not simply `//foo:bar` or `:bar`. The additional `@` is
166+
needed because the strings are evaluated in a different context than where
167+
they originate.
168+
:::
169+
170+
The list of settings will be applied atop of any of the local runtime's
171+
settings that are used for {obj}`toolchain.target_settings`. i.e. they are
172+
evaluated first and guard the checking of the local runtime's auto-detected
173+
conditions.
174+
175+
This allows a local toolchain to only be used if certain flags or
176+
config setting conditions are met. Such conditions can include user-defined
177+
flags, platform constraints, etc.
178+
179+
See the [Local toolchains] docs for examples and further information.
180+
181+
:::{seealso}
182+
The `target_compatible_with` attribute, which handles *constraint* values,
183+
instead of `config_settings`.
184+
:::
185+
186+
:::{versionadded} VERSION_NEXT_FEATURE
187+
:::
79188
""",
80189
),
81190
"_rule_name": attr.string(default = "local_toolchains_repo"),

python/private/py_toolchain_suite.bzl

+61-10
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Create the toolchain defs in a BUILD.bazel file."""
1616

1717
load("@bazel_skylib//lib:selects.bzl", "selects")
18+
load("@platforms//host:constraints.bzl", "HOST_CONSTRAINTS")
1819
load(":text_util.bzl", "render")
1920
load(
2021
":toolchain_types.bzl",
@@ -95,9 +96,15 @@ def py_toolchain_suite(
9596
runtime_repo_name = user_repository_name,
9697
target_settings = target_settings,
9798
target_compatible_with = target_compatible_with,
99+
exec_compatible_with = [],
98100
)
99101

100-
def _internal_toolchain_suite(prefix, runtime_repo_name, target_compatible_with, target_settings):
102+
def _internal_toolchain_suite(
103+
prefix,
104+
runtime_repo_name,
105+
target_compatible_with,
106+
target_settings,
107+
exec_compatible_with):
101108
native.toolchain(
102109
name = "{prefix}_toolchain".format(prefix = prefix),
103110
toolchain = "@{runtime_repo_name}//:python_runtimes".format(
@@ -106,6 +113,7 @@ def _internal_toolchain_suite(prefix, runtime_repo_name, target_compatible_with,
106113
toolchain_type = TARGET_TOOLCHAIN_TYPE,
107114
target_settings = target_settings,
108115
target_compatible_with = target_compatible_with,
116+
exec_compatible_with = exec_compatible_with,
109117
)
110118

111119
native.toolchain(
@@ -116,6 +124,7 @@ def _internal_toolchain_suite(prefix, runtime_repo_name, target_compatible_with,
116124
toolchain_type = PY_CC_TOOLCHAIN_TYPE,
117125
target_settings = target_settings,
118126
target_compatible_with = target_compatible_with,
127+
exec_compatible_with = exec_compatible_with,
119128
)
120129

121130
native.toolchain(
@@ -142,7 +151,13 @@ def _internal_toolchain_suite(prefix, runtime_repo_name, target_compatible_with,
142151
# call in python/repositories.bzl. Bzlmod doesn't need anything; it will
143152
# register `:all`.
144153

145-
def define_local_toolchain_suites(name, version_aware_repo_names, version_unaware_repo_names):
154+
def define_local_toolchain_suites(
155+
name,
156+
version_aware_repo_names,
157+
version_unaware_repo_names,
158+
repo_exec_compatible_with,
159+
repo_target_compatible_with,
160+
repo_target_settings):
146161
"""Define toolchains for `local_runtime_repo` backed toolchains.
147162
148163
This generates `toolchain` targets that can be registered using `:all`. The
@@ -156,24 +171,60 @@ def define_local_toolchain_suites(name, version_aware_repo_names, version_unawar
156171
version-aware toolchains defined.
157172
version_unaware_repo_names: `list[str]` of the repo names that will have
158173
version-unaware toolchains defined.
174+
repo_target_settings: {type}`dict[str, list[str]]` mapping of repo names
175+
to string labels that are added to the `target_settings` for the
176+
respective repo's toolchain.
177+
repo_target_compatible_with: {type}`dict[str, list[str]]` mapping of repo names
178+
to string labels that are added to the `target_compatible_with` for
179+
the respective repo's toolchain.
180+
repo_exec_compatible_with: {type}`dict[str, list[str]]` mapping of repo names
181+
to string labels that are added to the `exec_compatible_with` for
182+
the respective repo's toolchain.
159183
"""
184+
160185
i = 0
161186
for i, repo in enumerate(version_aware_repo_names, start = i):
162-
prefix = render.left_pad_zero(i, 4)
187+
target_settings = ["@{}//:is_matching_python_version".format(repo)]
188+
189+
if repo_target_settings.get(repo):
190+
selects.config_setting_group(
191+
name = "_{}_user_guard".format(repo),
192+
match_all = repo_target_settings.get(repo, []) + target_settings,
193+
)
194+
target_settings = ["_{}_user_guard".format(repo)]
163195
_internal_toolchain_suite(
164-
prefix = prefix,
196+
prefix = render.left_pad_zero(i, 4),
165197
runtime_repo_name = repo,
166-
target_compatible_with = ["@{}//:os".format(repo)],
167-
target_settings = ["@{}//:is_matching_python_version".format(repo)],
198+
target_compatible_with = _get_local_toolchain_target_compatible_with(
199+
repo,
200+
repo_target_compatible_with,
201+
),
202+
target_settings = target_settings,
203+
exec_compatible_with = repo_exec_compatible_with.get(repo, []),
168204
)
169205

170206
# The version unaware entries must go last because they will match any Python
171207
# version.
172208
for i, repo in enumerate(version_unaware_repo_names, start = i + 1):
173-
prefix = render.left_pad_zero(i, 4)
174209
_internal_toolchain_suite(
175-
prefix = prefix,
210+
prefix = render.left_pad_zero(i, 4) + "_default",
176211
runtime_repo_name = repo,
177-
target_settings = [],
178-
target_compatible_with = ["@{}//:os".format(repo)],
212+
target_compatible_with = _get_local_toolchain_target_compatible_with(
213+
repo,
214+
repo_target_compatible_with,
215+
),
216+
# We don't call _get_local_toolchain_target_settings because that
217+
# will add the version matching condition by default.
218+
target_settings = repo_target_settings.get(repo, []),
219+
exec_compatible_with = repo_exec_compatible_with.get(repo, []),
179220
)
221+
222+
def _get_local_toolchain_target_compatible_with(repo, repo_target_compatible_with):
223+
if repo in repo_target_compatible_with:
224+
target_compatible_with = repo_target_compatible_with[repo]
225+
if "HOST_CONSTRAINTS" in target_compatible_with:
226+
target_compatible_with.remove("HOST_CONSTRAINTS")
227+
target_compatible_with.extend(HOST_CONSTRAINTS)
228+
else:
229+
target_compatible_with = ["@{}//:os".format(repo)]
230+
return target_compatible_with

0 commit comments

Comments
 (0)