1# Copyright 2022 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"""Create a repository to hold the toolchains. 16 17This follows guidance here: 18https://docs.bazel.build/versions/main/skylark/deploying.html#registering-toolchains 19 20The "complex computation" in our case is simply downloading large artifacts. 21This guidance tells us how to avoid that: we put the toolchain targets in the 22alias repository with only the toolchain attribute pointing into the 23platform-specific repositories. 24""" 25 26load( 27 "//python:versions.bzl", 28 "LINUX_NAME", 29 "MACOS_NAME", 30 "PLATFORMS", 31 "WINDOWS_NAME", 32) 33load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils") 34load("//python/private:text_util.bzl", "render") 35 36def get_repository_name(repository_workspace): 37 dummy_label = "//:_" 38 return str(repository_workspace.relative(dummy_label))[:-len(dummy_label)] or "@" 39 40def python_toolchain_build_file_content( 41 prefix, 42 python_version, 43 set_python_version_constraint, 44 user_repository_name): 45 """Creates the content for toolchain definitions for a build file. 46 47 Args: 48 prefix: Python toolchain name prefixes 49 python_version: Python versions for the toolchains 50 set_python_version_constraint: string, "True" if the toolchain should 51 have the Python version constraint added as a requirement for 52 matching the toolchain, "False" if not. 53 user_repository_name: names for the user repos 54 55 Returns: 56 build_content: Text containing toolchain definitions 57 """ 58 59 return "\n\n".join([ 60 """\ 61py_toolchain_suite( 62 user_repository_name = "{user_repository_name}_{platform}", 63 prefix = "{prefix}{platform}", 64 target_compatible_with = {compatible_with}, 65 flag_values = {flag_values}, 66 python_version = "{python_version}", 67 set_python_version_constraint = "{set_python_version_constraint}", 68)""".format( 69 compatible_with = render.indent(render.list(meta.compatible_with)).lstrip(), 70 flag_values = render.indent(render.dict( 71 meta.flag_values, 72 key_repr = lambda x: repr(str(x)), # this is to correctly display labels 73 )).lstrip(), 74 platform = platform, 75 set_python_version_constraint = set_python_version_constraint, 76 user_repository_name = user_repository_name, 77 prefix = prefix, 78 python_version = python_version, 79 ) 80 for platform, meta in PLATFORMS.items() 81 ]) 82 83def _toolchains_repo_impl(rctx): 84 build_content = """\ 85# Generated by python/private/toolchains_repo.bzl 86# 87# These can be registered in the workspace file or passed to --extra_toolchains 88# flag. By default all these toolchains are registered by the 89# python_register_toolchains macro so you don't normally need to interact with 90# these targets. 91 92load("@{rules_python}//python/private:py_toolchain_suite.bzl", "py_toolchain_suite") 93 94""".format( 95 rules_python = rctx.attr._rules_python_workspace.workspace_name, 96 ) 97 98 toolchains = python_toolchain_build_file_content( 99 prefix = "", 100 python_version = rctx.attr.python_version, 101 set_python_version_constraint = str(rctx.attr.set_python_version_constraint), 102 user_repository_name = rctx.attr.user_repository_name, 103 ) 104 105 rctx.file("BUILD.bazel", build_content + toolchains) 106 107toolchains_repo = repository_rule( 108 _toolchains_repo_impl, 109 doc = "Creates a repository with toolchain definitions for all known platforms " + 110 "which can be registered or selected.", 111 attrs = { 112 "python_version": attr.string(doc = "The Python version."), 113 "set_python_version_constraint": attr.bool(doc = "if target_compatible_with for the toolchain should set the version constraint"), 114 "user_repository_name": attr.string(doc = "what the user chose for the base name"), 115 "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")), 116 }, 117) 118 119def _toolchain_aliases_impl(rctx): 120 logger = repo_utils.logger(rctx) 121 (os_name, arch) = _get_host_os_arch(rctx, logger) 122 123 host_platform = _get_host_platform(os_name, arch) 124 125 is_windows = (os_name == WINDOWS_NAME) 126 python3_binary_path = "python.exe" if is_windows else "bin/python3" 127 128 # Base BUILD file for this repository. 129 build_contents = """\ 130# Generated by python/private/toolchains_repo.bzl 131package(default_visibility = ["//visibility:public"]) 132load("@rules_python//python:versions.bzl", "gen_python_config_settings") 133gen_python_config_settings() 134exports_files(["defs.bzl"]) 135 136PLATFORMS = [ 137{loaded_platforms} 138] 139alias(name = "files", actual = select({{":" + item: "@{py_repository}_" + item + "//:files" for item in PLATFORMS}})) 140alias(name = "includes", actual = select({{":" + item: "@{py_repository}_" + item + "//:includes" for item in PLATFORMS}})) 141alias(name = "libpython", actual = select({{":" + item: "@{py_repository}_" + item + "//:libpython" for item in PLATFORMS}})) 142alias(name = "py3_runtime", actual = select({{":" + item: "@{py_repository}_" + item + "//:py3_runtime" for item in PLATFORMS}})) 143alias(name = "python_headers", actual = select({{":" + item: "@{py_repository}_" + item + "//:python_headers" for item in PLATFORMS}})) 144alias(name = "python_runtimes", actual = select({{":" + item: "@{py_repository}_" + item + "//:python_runtimes" for item in PLATFORMS}})) 145alias(name = "python3", actual = select({{":" + item: "@{py_repository}_" + item + "//:" + ("python.exe" if "windows" in item else "bin/python3") for item in PLATFORMS}})) 146""".format( 147 py_repository = rctx.attr.user_repository_name, 148 loaded_platforms = "\n".join([" \"{}\",".format(p) for p in rctx.attr.platforms]), 149 ) 150 if not is_windows: 151 build_contents += """\ 152alias(name = "pip", actual = select({{":" + item: "@{py_repository}_" + item + "//:python_runtimes" for item in PLATFORMS if "windows" not in item}})) 153""".format( 154 py_repository = rctx.attr.user_repository_name, 155 host_platform = host_platform, 156 ) 157 rctx.file("BUILD.bazel", build_contents) 158 159 # Expose a Starlark file so rules can know what host platform we used and where to find an interpreter 160 # when using repository_ctx.path, which doesn't understand aliases. 161 rctx.file("defs.bzl", content = """\ 162# Generated by python/private/toolchains_repo.bzl 163 164load( 165 "{rules_python}//python/config_settings:transition.bzl", 166 _py_binary = "py_binary", 167 _py_test = "py_test", 168) 169load( 170 "{rules_python}//python/entry_points:py_console_script_binary.bzl", 171 _py_console_script_binary = "py_console_script_binary", 172) 173load("{rules_python}//python:pip.bzl", _compile_pip_requirements = "compile_pip_requirements") 174 175host_platform = "{host_platform}" 176interpreter = "@{py_repository}_{host_platform}//:{python3_binary_path}" 177 178def py_binary(name, **kwargs): 179 return _py_binary( 180 name = name, 181 python_version = "{python_version}", 182 **kwargs 183 ) 184 185def py_console_script_binary(name, **kwargs): 186 return _py_console_script_binary( 187 name = name, 188 binary_rule = py_binary, 189 **kwargs 190 ) 191 192def py_test(name, **kwargs): 193 return _py_test( 194 name = name, 195 python_version = "{python_version}", 196 **kwargs 197 ) 198 199def compile_pip_requirements(name, **kwargs): 200 return _compile_pip_requirements( 201 name = name, 202 py_binary = py_binary, 203 py_test = py_test, 204 **kwargs 205 ) 206 207""".format( 208 host_platform = host_platform, 209 py_repository = rctx.attr.user_repository_name, 210 python_version = rctx.attr.python_version, 211 python3_binary_path = python3_binary_path, 212 rules_python = get_repository_name(rctx.attr._rules_python_workspace), 213 )) 214 215toolchain_aliases = repository_rule( 216 _toolchain_aliases_impl, 217 doc = """\ 218Creates a repository with a shorter name only referencing the python version, 219it contains a BUILD.bazel file declaring aliases to the host platform's targets 220and is a great fit for any usage related to setting up toolchains for build 221actions.""", 222 attrs = { 223 "platforms": attr.string_list( 224 doc = "List of platforms for which aliases shall be created", 225 ), 226 "python_version": attr.string(doc = "The Python version."), 227 "user_repository_name": attr.string( 228 mandatory = True, 229 doc = "The base name for all created repositories, like 'python38'.", 230 ), 231 "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")), 232 }, 233 environ = [REPO_DEBUG_ENV_VAR], 234) 235 236def _host_toolchain_impl(rctx): 237 logger = repo_utils.logger(rctx) 238 rctx.file("BUILD.bazel", """\ 239# Generated by python/private/toolchains_repo.bzl 240 241exports_files(["python"], visibility = ["//visibility:public"]) 242""") 243 244 (os_name, arch) = _get_host_os_arch(rctx, logger) 245 host_platform = _get_host_platform(os_name, arch) 246 repo = "@@{py_repository}_{host_platform}".format( 247 py_repository = rctx.attr.name[:-len("_host")], 248 host_platform = host_platform, 249 ) 250 251 rctx.report_progress("Symlinking interpreter files to the target platform") 252 host_python_repo = rctx.path(Label("{repo}//:BUILD.bazel".format(repo = repo))) 253 254 # The interpreter might not work on platfroms that don't have symlink support if 255 # we just symlink the interpreter itself. rctx.symlink does a copy in such cases 256 # so we can just attempt to symlink all of the directories in the host interpreter 257 # repo, which should be faster than re-downloading it. 258 for p in host_python_repo.dirname.readdir(): 259 if p.basename in [ 260 # ignore special files created by the repo rule automatically 261 "BUILD.bazel", 262 "MODULE.bazel", 263 "REPO.bazel", 264 "WORKSPACE", 265 "WORKSPACE.bazel", 266 "WORKSPACE.bzlmod", 267 ]: 268 continue 269 270 # symlink works on all platforms that bazel supports, so it should work on 271 # UNIX and Windows with and without symlink support. For better performance 272 # users should enable the symlink startup option, however that requires admin 273 # privileges. 274 rctx.symlink(p, p.basename) 275 276 is_windows = (os_name == WINDOWS_NAME) 277 python_binary = "python.exe" if is_windows else "python" 278 279 # Ensure that we can run the interpreter and check that we are not 280 # using the host interpreter. 281 python_tester_contents = """\ 282from pathlib import Path 283import sys 284 285python = Path(sys.executable) 286want_python = str(Path("{python}").resolve()) 287got_python = str(Path(sys.executable).resolve()) 288 289assert want_python == got_python, \ 290 "Expected to use a different interpreter:\\nwant: '{{}}'\\n got: '{{}}'".format( 291 want_python, 292 got_python, 293 ) 294""".format(repo = repo.strip("@"), python = python_binary) 295 python_tester = rctx.path("python_tester.py") 296 rctx.file(python_tester, python_tester_contents) 297 repo_utils.execute_checked( 298 rctx, 299 op = "CheckHostInterpreter", 300 arguments = [rctx.path(python_binary), python_tester], 301 ) 302 if not rctx.delete(python_tester): 303 fail("Failed to delete the python tester") 304 305host_toolchain = repository_rule( 306 _host_toolchain_impl, 307 doc = """\ 308Creates a repository with a shorter name meant to be used in the repository_ctx, 309which needs to have `symlinks` for the interpreter. This is separate from the 310toolchain_aliases repo because referencing the `python` interpreter target from 311this repo causes an eager fetch of the toolchain for the host platform. 312 """, 313 attrs = { 314 "_rule_name": attr.string(default = "host_toolchain"), 315 "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")), 316 }, 317) 318 319def _multi_toolchain_aliases_impl(rctx): 320 rules_python = rctx.attr._rules_python_workspace.workspace_name 321 322 for python_version, repository_name in rctx.attr.python_versions.items(): 323 file = "{}/defs.bzl".format(python_version) 324 rctx.file(file, content = """\ 325# Generated by python/private/toolchains_repo.bzl 326 327load( 328 "@{repository_name}//:defs.bzl", 329 _compile_pip_requirements = "compile_pip_requirements", 330 _host_platform = "host_platform", 331 _interpreter = "interpreter", 332 _py_binary = "py_binary", 333 _py_console_script_binary = "py_console_script_binary", 334 _py_test = "py_test", 335) 336 337compile_pip_requirements = _compile_pip_requirements 338host_platform = _host_platform 339interpreter = _interpreter 340py_binary = _py_binary 341py_console_script_binary = _py_console_script_binary 342py_test = _py_test 343""".format( 344 repository_name = repository_name, 345 )) 346 rctx.file("{}/BUILD.bazel".format(python_version), "") 347 348 pip_bzl = """\ 349# Generated by python/private/toolchains_repo.bzl 350 351load("@{rules_python}//python:pip.bzl", "pip_parse", _multi_pip_parse = "multi_pip_parse") 352 353def multi_pip_parse(name, requirements_lock, **kwargs): 354 return _multi_pip_parse( 355 name = name, 356 python_versions = {python_versions}, 357 requirements_lock = requirements_lock, 358 minor_mapping = {minor_mapping}, 359 **kwargs 360 ) 361 362""".format( 363 python_versions = rctx.attr.python_versions.keys(), 364 minor_mapping = render.indent(render.dict(rctx.attr.minor_mapping), indent = " " * 8).lstrip(), 365 rules_python = rules_python, 366 ) 367 rctx.file("pip.bzl", content = pip_bzl) 368 rctx.file("BUILD.bazel", "") 369 370multi_toolchain_aliases = repository_rule( 371 _multi_toolchain_aliases_impl, 372 attrs = { 373 "minor_mapping": attr.string_dict(doc = "The mapping between `X.Y` and `X.Y.Z` python version values"), 374 "python_versions": attr.string_dict(doc = "The Python versions."), 375 "_rules_python_workspace": attr.label(default = Label("//:WORKSPACE")), 376 }, 377) 378 379def sanitize_platform_name(platform): 380 return platform.replace("-", "_") 381 382def _get_host_platform(os_name, arch): 383 """Gets the host platform. 384 385 Args: 386 os_name: the host OS name. 387 arch: the host arch. 388 Returns: 389 The host platform. 390 """ 391 host_platform = None 392 for platform, meta in PLATFORMS.items(): 393 if meta.os_name == os_name and meta.arch == arch: 394 host_platform = platform 395 if not host_platform: 396 fail("No platform declared for host OS {} on arch {}".format(os_name, arch)) 397 return host_platform 398 399def _get_host_os_arch(rctx, logger): 400 """Infer the host OS name and arch from a repository context. 401 402 Args: 403 rctx: Bazel's repository_ctx. 404 logger: Logger to use for operations. 405 406 Returns: 407 A tuple with the host OS name and arch. 408 """ 409 os_name = rctx.os.name 410 411 # We assume the arch for Windows is always x86_64. 412 if "windows" in os_name.lower(): 413 arch = "x86_64" 414 415 # Normalize the os_name. E.g. os_name could be "OS windows server 2019". 416 os_name = WINDOWS_NAME 417 else: 418 # This is not ideal, but bazel doesn't directly expose arch. 419 arch = repo_utils.execute_unchecked( 420 rctx, 421 op = "GetUname", 422 arguments = [repo_utils.which_checked(rctx, "uname"), "-m"], 423 logger = logger, 424 ).stdout.strip() 425 426 # Normalize the os_name. 427 if "mac" in os_name.lower(): 428 os_name = MACOS_NAME 429 elif "linux" in os_name.lower(): 430 os_name = LINUX_NAME 431 432 return (os_name, arch) 433