• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2023 The Bazel Authors. All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"Python toolchain module extensions for use with bzlmod"
16
17load("//python:repositories.bzl", "python_register_toolchains")
18load("//python/extensions/private:pythons_hub.bzl", "hub_repo")
19load("//python/private:toolchains_repo.bzl", "multi_toolchain_aliases")
20
21# This limit can be increased essentially arbitrarily, but doing so will cause a rebuild of all
22# targets using any of these toolchains due to the changed repository name.
23_MAX_NUM_TOOLCHAINS = 9999
24_TOOLCHAIN_INDEX_PAD_LENGTH = len(str(_MAX_NUM_TOOLCHAINS))
25
26def _toolchain_prefix(index, name):
27    """Prefixes the given name with the index, padded with zeros to ensure lexicographic sorting.
28
29    Examples:
30      _toolchain_prefix(   2, "foo") == "_0002_foo_"
31      _toolchain_prefix(2000, "foo") == "_2000_foo_"
32    """
33    return "_{}_{}_".format(_left_pad_zero(index, _TOOLCHAIN_INDEX_PAD_LENGTH), name)
34
35def _left_pad_zero(index, length):
36    if index < 0:
37        fail("index must be non-negative")
38    return ("0" * length + str(index))[-length:]
39
40# Printing a warning msg not debugging, so we have to disable
41# the buildifier check.
42# buildifier: disable=print
43def _print_warn(msg):
44    print("WARNING:", msg)
45
46def _python_register_toolchains(name, toolchain_attr, version_constraint):
47    """Calls python_register_toolchains and returns a struct used to collect the toolchains.
48    """
49    python_register_toolchains(
50        name = name,
51        python_version = toolchain_attr.python_version,
52        register_coverage_tool = toolchain_attr.configure_coverage_tool,
53        ignore_root_user_error = toolchain_attr.ignore_root_user_error,
54        set_python_version_constraint = version_constraint,
55    )
56    return struct(
57        python_version = toolchain_attr.python_version,
58        set_python_version_constraint = str(version_constraint),
59        name = name,
60    )
61
62def _python_impl(module_ctx):
63    # The toolchain info structs to register, in the order to register them in.
64    toolchains = []
65
66    # We store the default toolchain separately to ensure it is the last
67    # toolchain added to toolchains.
68    default_toolchain = None
69
70    # Map of string Major.Minor to the toolchain name and module name
71    global_toolchain_versions = {}
72
73    for mod in module_ctx.modules:
74        module_toolchain_versions = []
75
76        for toolchain_attr in mod.tags.toolchain:
77            toolchain_version = toolchain_attr.python_version
78            toolchain_name = "python_" + toolchain_version.replace(".", "_")
79
80            # Duplicate versions within a module indicate a misconfigured module.
81            if toolchain_version in module_toolchain_versions:
82                _fail_duplicate_module_toolchain_version(toolchain_version, mod.name)
83            module_toolchain_versions.append(toolchain_version)
84
85            # Ignore version collisions in the global scope because there isn't
86            # much else that can be done. Modules don't know and can't control
87            # what other modules do, so the first in the dependency graph wins.
88            if toolchain_version in global_toolchain_versions:
89                _warn_duplicate_global_toolchain_version(
90                    toolchain_version,
91                    first = global_toolchain_versions[toolchain_version],
92                    second_toolchain_name = toolchain_name,
93                    second_module_name = mod.name,
94                )
95                continue
96            global_toolchain_versions[toolchain_version] = struct(
97                toolchain_name = toolchain_name,
98                module_name = mod.name,
99            )
100
101            # Only the root module and rules_python are allowed to specify the default
102            # toolchain for a couple reasons:
103            # * It prevents submodules from specifying different defaults and only
104            #   one of them winning.
105            # * rules_python needs to set a soft default in case the root module doesn't,
106            #   e.g. if the root module doesn't use Python itself.
107            # * The root module is allowed to override the rules_python default.
108            if mod.is_root:
109                # A single toolchain is treated as the default because it's unambiguous.
110                is_default = toolchain_attr.is_default or len(mod.tags.toolchain) == 1
111            elif mod.name == "rules_python" and not default_toolchain:
112                # We don't do the len() check because we want the default that rules_python
113                # sets to be clearly visible.
114                is_default = toolchain_attr.is_default
115            else:
116                is_default = False
117
118            # We have already found one default toolchain, and we can only have
119            # one.
120            if is_default and default_toolchain != None:
121                _fail_multiple_default_toolchains(
122                    first = default_toolchain.name,
123                    second = toolchain_name,
124                )
125
126            toolchain_info = _python_register_toolchains(
127                toolchain_name,
128                toolchain_attr,
129                version_constraint = not is_default,
130            )
131
132            if is_default:
133                default_toolchain = toolchain_info
134            else:
135                toolchains.append(toolchain_info)
136
137    # A default toolchain is required so that the non-version-specific rules
138    # are able to match a toolchain.
139    if default_toolchain == None:
140        fail("No default Python toolchain configured. Is rules_python missing `is_default=True`?")
141
142    # The last toolchain in the BUILD file is set as the default
143    # toolchain. We need the default last.
144    toolchains.append(default_toolchain)
145
146    if len(toolchains) > _MAX_NUM_TOOLCHAINS:
147        fail("more than {} python versions are not supported".format(_MAX_NUM_TOOLCHAINS))
148
149    # Create the pythons_hub repo for the interpreter meta data and the
150    # the various toolchains.
151    hub_repo(
152        name = "pythons_hub",
153        default_python_version = default_toolchain.python_version,
154        toolchain_prefixes = [
155            _toolchain_prefix(index, toolchain.name)
156            for index, toolchain in enumerate(toolchains)
157        ],
158        toolchain_python_versions = [t.python_version for t in toolchains],
159        toolchain_set_python_version_constraints = [t.set_python_version_constraint for t in toolchains],
160        toolchain_user_repository_names = [t.name for t in toolchains],
161    )
162
163    # This is require in order to support multiple version py_test
164    # and py_binary
165    multi_toolchain_aliases(
166        name = "python_versions",
167        python_versions = {
168            version: entry.toolchain_name
169            for version, entry in global_toolchain_versions.items()
170        },
171    )
172
173def _fail_duplicate_module_toolchain_version(version, module):
174    fail(("Duplicate module toolchain version: module '{module}' attempted " +
175          "to use version '{version}' multiple times in itself").format(
176        version = version,
177        module = module,
178    ))
179
180def _warn_duplicate_global_toolchain_version(version, first, second_toolchain_name, second_module_name):
181    _print_warn((
182        "Ignoring toolchain '{second_toolchain}' from module '{second_module}': " +
183        "Toolchain '{first_toolchain}' from module '{first_module}' " +
184        "already registered Python version {version} and has precedence"
185    ).format(
186        first_toolchain = first.toolchain_name,
187        first_module = first.module_name,
188        second_module = second_module_name,
189        second_toolchain = second_toolchain_name,
190        version = version,
191    ))
192
193def _fail_multiple_default_toolchains(first, second):
194    fail(("Multiple default toolchains: only one toolchain " +
195          "can have is_default=True. First default " +
196          "was toolchain '{first}'. Second was '{second}'").format(
197        first = first,
198        second = second,
199    ))
200
201python = module_extension(
202    doc = """Bzlmod extension that is used to register Python toolchains.
203""",
204    implementation = _python_impl,
205    tag_classes = {
206        "toolchain": tag_class(
207            doc = """Tag class used to register Python toolchains.
208Use this tag class to register one or more Python toolchains. This class
209is also potentially called by sub modules. The following covers different
210business rules and use cases.
211
212Toolchains in the Root Module
213
214This class registers all toolchains in the root module.
215
216Toolchains in Sub Modules
217
218It will create a toolchain that is in a sub module, if the toolchain
219of the same name does not exist in the root module.  The extension stops name
220clashing between toolchains in the root module and toolchains in sub modules.
221You cannot configure more than one toolchain as the default toolchain.
222
223Toolchain set as the default version
224
225This extension will not create a toolchain that exists in a sub module,
226if the sub module toolchain is marked as the default version. If you have
227more than one toolchain in your root module, you need to set one of the
228toolchains as the default version.  If there is only one toolchain it
229is set as the default toolchain.
230
231Toolchain repository name
232
233A toolchain's repository name uses the format `python_{major}_{minor}`, e.g.
234`python_3_10`. The `major` and `minor` components are
235`major` and `minor` are the Python version from the `python_version` attribute.
236""",
237            attrs = {
238                "configure_coverage_tool": attr.bool(
239                    mandatory = False,
240                    doc = "Whether or not to configure the default coverage tool for the toolchains.",
241                ),
242                "ignore_root_user_error": attr.bool(
243                    default = False,
244                    doc = "Whether the check for root should be ignored or not. This causes cache misses with .pyc files.",
245                    mandatory = False,
246                ),
247                "is_default": attr.bool(
248                    mandatory = False,
249                    doc = "Whether the toolchain is the default version",
250                ),
251                "python_version": attr.string(
252                    mandatory = True,
253                    doc = "The Python version, in `major.minor` format, e.g " +
254                          "'3.12', to create a toolchain for. Patch level " +
255                          "granularity (e.g. '3.12.1') is not supported.",
256                ),
257            },
258        ),
259    },
260)
261