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