-
Notifications
You must be signed in to change notification settings - Fork 541
/
python.bzl
362 lines (310 loc) · 15 KB
/
python.bzl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# Copyright 2023 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"Python toolchain module extensions for use with bzlmod"
load("@bazel_features//:features.bzl", "bazel_features")
load("//python:repositories.bzl", "python_register_toolchains")
load(":pythons_hub.bzl", "hub_repo")
load(":toolchains_repo.bzl", "multi_toolchain_aliases")
load(":util.bzl", "IS_BAZEL_6_4_OR_HIGHER")
# This limit can be increased essentially arbitrarily, but doing so will cause a rebuild of all
# targets using any of these toolchains due to the changed repository name.
_MAX_NUM_TOOLCHAINS = 9999
_TOOLCHAIN_INDEX_PAD_LENGTH = len(str(_MAX_NUM_TOOLCHAINS))
def _toolchain_prefix(index, name):
"""Prefixes the given name with the index, padded with zeros to ensure lexicographic sorting.
Examples:
_toolchain_prefix( 2, "foo") == "_0002_foo_"
_toolchain_prefix(2000, "foo") == "_2000_foo_"
"""
return "_{}_{}_".format(_left_pad_zero(index, _TOOLCHAIN_INDEX_PAD_LENGTH), name)
def _left_pad_zero(index, length):
if index < 0:
fail("index must be non-negative")
return ("0" * length + str(index))[-length:]
# Printing a warning msg not debugging, so we have to disable
# the buildifier check.
# buildifier: disable=print
def _print_warn(msg):
print("WARNING:", msg)
def _python_register_toolchains(name, toolchain_attr, module, ignore_root_user_error):
"""Calls python_register_toolchains and returns a struct used to collect the toolchains.
"""
python_register_toolchains(
name = name,
python_version = toolchain_attr.python_version,
register_coverage_tool = toolchain_attr.configure_coverage_tool,
ignore_root_user_error = ignore_root_user_error,
)
return struct(
python_version = toolchain_attr.python_version,
name = name,
module = struct(name = module.name, is_root = module.is_root),
)
def _python_impl(module_ctx):
if module_ctx.os.environ.get("RULES_PYTHON_BZLMOD_DEBUG", "0") == "1":
debug_info = {
"toolchains_registered": [],
}
else:
debug_info = None
# The toolchain_info structs to register, in the order to register them in.
# NOTE: The last element is special: it is treated as the default toolchain,
# so there is special handling to ensure the last entry is the correct one.
toolchains = []
# We store the default toolchain separately to ensure it is the last
# toolchain added to toolchains.
# This is a toolchain_info struct.
default_toolchain = None
# Map of string Major.Minor to the toolchain_info struct
global_toolchain_versions = {}
ignore_root_user_error = None
# if the root module does not register any toolchain then the
# ignore_root_user_error takes its default value: False
if not module_ctx.modules[0].tags.toolchain:
ignore_root_user_error = False
for mod in module_ctx.modules:
module_toolchain_versions = []
for toolchain_attr in mod.tags.toolchain:
toolchain_version = toolchain_attr.python_version
toolchain_name = "python_" + toolchain_version.replace(".", "_")
# Duplicate versions within a module indicate a misconfigured module.
if toolchain_version in module_toolchain_versions:
_fail_duplicate_module_toolchain_version(toolchain_version, mod.name)
module_toolchain_versions.append(toolchain_version)
if mod.is_root:
# Only the root module and rules_python are allowed to specify the default
# toolchain for a couple reasons:
# * It prevents submodules from specifying different defaults and only
# one of them winning.
# * rules_python needs to set a soft default in case the root module doesn't,
# e.g. if the root module doesn't use Python itself.
# * The root module is allowed to override the rules_python default.
# A single toolchain is treated as the default because it's unambiguous.
is_default = toolchain_attr.is_default or len(mod.tags.toolchain) == 1
# Also only the root module should be able to decide ignore_root_user_error.
# Modules being depended upon don't know the final environment, so they aren't
# in the right position to know or decide what the correct setting is.
# If an inconsistency in the ignore_root_user_error among multiple toolchains is detected, fail.
if ignore_root_user_error != None and toolchain_attr.ignore_root_user_error != ignore_root_user_error:
fail("Toolchains in the root module must have consistent 'ignore_root_user_error' attributes")
ignore_root_user_error = toolchain_attr.ignore_root_user_error
elif mod.name == "rules_python" and not default_toolchain:
# We don't do the len() check because we want the default that rules_python
# sets to be clearly visible.
is_default = toolchain_attr.is_default
else:
is_default = False
if is_default and default_toolchain != None:
_fail_multiple_default_toolchains(
first = default_toolchain.name,
second = toolchain_name,
)
# Ignore version collisions in the global scope because there isn't
# much else that can be done. Modules don't know and can't control
# what other modules do, so the first in the dependency graph wins.
if toolchain_version in global_toolchain_versions:
# If the python version is explicitly provided by the root
# module, they should not be warned for choosing the same
# version that rules_python provides as default.
first = global_toolchain_versions[toolchain_version]
if mod.name != "rules_python" or not first.module.is_root:
_warn_duplicate_global_toolchain_version(
toolchain_version,
first = first,
second_toolchain_name = toolchain_name,
second_module_name = mod.name,
)
toolchain_info = None
else:
toolchain_info = _python_register_toolchains(
toolchain_name,
toolchain_attr,
module = mod,
ignore_root_user_error = ignore_root_user_error,
)
global_toolchain_versions[toolchain_version] = toolchain_info
if debug_info:
debug_info["toolchains_registered"].append({
"ignore_root_user_error": ignore_root_user_error,
"name": toolchain_name,
})
if is_default:
# This toolchain is setting the default, but the actual
# registration was performed previously, by a different module.
if toolchain_info == None:
default_toolchain = global_toolchain_versions[toolchain_version]
# Remove it because later code will add it at the end to
# ensure it is last in the list.
toolchains.remove(default_toolchain)
else:
default_toolchain = toolchain_info
elif toolchain_info:
toolchains.append(toolchain_info)
# A default toolchain is required so that the non-version-specific rules
# are able to match a toolchain.
if default_toolchain == None:
fail("No default Python toolchain configured. Is rules_python missing `is_default=True`?")
elif default_toolchain.python_version not in global_toolchain_versions:
fail('Default version "{python_version}" selected by module ' +
'"{module_name}", but no toolchain with that version registered'.format(
python_version = default_toolchain.python_version,
module_name = default_toolchain.module.name,
))
# The last toolchain in the BUILD file is set as the default
# toolchain. We need the default last.
toolchains.append(default_toolchain)
if len(toolchains) > _MAX_NUM_TOOLCHAINS:
fail("more than {} python versions are not supported".format(_MAX_NUM_TOOLCHAINS))
# Create the pythons_hub repo for the interpreter meta data and the
# the various toolchains.
hub_repo(
name = "pythons_hub",
default_python_version = default_toolchain.python_version,
toolchain_prefixes = [
_toolchain_prefix(index, toolchain.name)
for index, toolchain in enumerate(toolchains)
],
toolchain_python_versions = [t.python_version for t in toolchains],
# The last toolchain is the default; it can't have version constraints
# Despite the implication of the arg name, the values are strs, not bools
toolchain_set_python_version_constraints = [
"True" if i != len(toolchains) - 1 else "False"
for i in range(len(toolchains))
],
toolchain_user_repository_names = [t.name for t in toolchains],
)
# This is require in order to support multiple version py_test
# and py_binary
multi_toolchain_aliases(
name = "python_versions",
python_versions = {
version: toolchain.name
for version, toolchain in global_toolchain_versions.items()
},
)
if debug_info != None:
_debug_repo(
name = "rules_python_bzlmod_debug",
debug_info = json.encode_indent(debug_info),
)
if bazel_features.external_deps.extension_metadata_has_reproducible:
return module_ctx.extension_metadata(reproducible = True)
else:
return None
def _fail_duplicate_module_toolchain_version(version, module):
fail(("Duplicate module toolchain version: module '{module}' attempted " +
"to use version '{version}' multiple times in itself").format(
version = version,
module = module,
))
def _warn_duplicate_global_toolchain_version(version, first, second_toolchain_name, second_module_name):
_print_warn((
"Ignoring toolchain '{second_toolchain}' from module '{second_module}': " +
"Toolchain '{first_toolchain}' from module '{first_module}' " +
"already registered Python version {version} and has precedence"
).format(
first_toolchain = first.name,
first_module = first.module.name,
second_module = second_module_name,
second_toolchain = second_toolchain_name,
version = version,
))
def _fail_multiple_default_toolchains(first, second):
fail(("Multiple default toolchains: only one toolchain " +
"can have is_default=True. First default " +
"was toolchain '{first}'. Second was '{second}'").format(
first = first,
second = second,
))
def _get_bazel_version_specific_kwargs():
kwargs = {}
if IS_BAZEL_6_4_OR_HIGHER:
kwargs["environ"] = ["RULES_PYTHON_BZLMOD_DEBUG"]
return kwargs
python = module_extension(
doc = """Bzlmod extension that is used to register Python toolchains.
""",
implementation = _python_impl,
tag_classes = {
"toolchain": tag_class(
doc = """Tag class used to register Python toolchains.
Use this tag class to register one or more Python toolchains. This class
is also potentially called by sub modules. The following covers different
business rules and use cases.
Toolchains in the Root Module
This class registers all toolchains in the root module.
Toolchains in Sub Modules
It will create a toolchain that is in a sub module, if the toolchain
of the same name does not exist in the root module. The extension stops name
clashing between toolchains in the root module and toolchains in sub modules.
You cannot configure more than one toolchain as the default toolchain.
Toolchain set as the default version
This extension will not create a toolchain that exists in a sub module,
if the sub module toolchain is marked as the default version. If you have
more than one toolchain in your root module, you need to set one of the
toolchains as the default version. If there is only one toolchain it
is set as the default toolchain.
Toolchain repository name
A toolchain's repository name uses the format `python_{major}_{minor}`, e.g.
`python_3_10`. The `major` and `minor` components are
`major` and `minor` are the Python version from the `python_version` attribute.
""",
attrs = {
"configure_coverage_tool": attr.bool(
mandatory = False,
doc = "Whether or not to configure the default coverage tool for the toolchains.",
),
"ignore_root_user_error": attr.bool(
default = False,
doc = """\
If False, the Python runtime installation will be made read only. This improves
the ability for Bazel to cache it, but prevents the interpreter from creating
pyc files for the standard library dynamically at runtime as they are loaded.
If True, the Python runtime installation is read-write. This allows the
interpreter to create pyc files for the standard library, but, because they are
created as needed, it adversely affects Bazel's ability to cache the runtime and
can result in spurious build failures.
""",
mandatory = False,
),
"is_default": attr.bool(
mandatory = False,
doc = "Whether the toolchain is the default version",
),
"python_version": attr.string(
mandatory = True,
doc = "The Python version, in `major.minor` format, e.g " +
"'3.12', to create a toolchain for. Patch level " +
"granularity (e.g. '3.12.1') is not supported.",
),
},
),
},
**_get_bazel_version_specific_kwargs()
)
_DEBUG_BUILD_CONTENT = """
package(
default_visibility = ["//visibility:public"],
)
exports_files(["debug_info.json"])
"""
def _debug_repo_impl(repo_ctx):
repo_ctx.file("BUILD.bazel", _DEBUG_BUILD_CONTENT)
repo_ctx.file("debug_info.json", repo_ctx.attr.debug_info)
_debug_repo = repository_rule(
implementation = _debug_repo_impl,
attrs = {
"debug_info": attr.string(),
},
)