1# Copyright 2024 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 for a locally installed Python runtime.""" 16 17load("//python/private:enum.bzl", "enum") 18load(":repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils") 19 20# buildifier: disable=name-conventions 21_OnFailure = enum( 22 SKIP = "skip", 23 WARN = "warn", 24 FAIL = "fail", 25) 26 27_TOOLCHAIN_IMPL_TEMPLATE = """\ 28# Generated by python/private/local_runtime_repo.bzl 29 30load("@rules_python//python/private:local_runtime_repo_setup.bzl", "define_local_runtime_toolchain_impl") 31 32define_local_runtime_toolchain_impl( 33 name = "local_runtime", 34 lib_ext = "{lib_ext}", 35 major = "{major}", 36 minor = "{minor}", 37 micro = "{micro}", 38 interpreter_path = "{interpreter_path}", 39 implementation_name = "{implementation_name}", 40 os = "{os}", 41) 42""" 43 44def _local_runtime_repo_impl(rctx): 45 logger = repo_utils.logger(rctx) 46 on_failure = rctx.attr.on_failure 47 48 result = _resolve_interpreter_path(rctx) 49 if not result.resolved_path: 50 if on_failure == "fail": 51 fail("interpreter not found: {}".format(result.describe_failure())) 52 53 if on_failure == "warn": 54 logger.warn(lambda: "interpreter not found: {}".format(result.describe_failure())) 55 56 # else, on_failure must be skip 57 rctx.file("BUILD.bazel", _expand_incompatible_template()) 58 return 59 else: 60 interpreter_path = result.resolved_path 61 62 logger.info(lambda: "resolved interpreter {} to {}".format(rctx.attr.interpreter_path, interpreter_path)) 63 64 exec_result = repo_utils.execute_unchecked( 65 rctx, 66 op = "local_runtime_repo.GetPythonInfo({})".format(rctx.name), 67 arguments = [ 68 interpreter_path, 69 rctx.path(rctx.attr._get_local_runtime_info), 70 ], 71 quiet = True, 72 logger = logger, 73 ) 74 if exec_result.return_code != 0: 75 if on_failure == "fail": 76 fail("GetPythonInfo failed: {}".format(exec_result.describe_failure())) 77 if on_failure == "warn": 78 logger.warn(lambda: "GetPythonInfo failed: {}".format(exec_result.describe_failure())) 79 80 # else, on_failure must be skip 81 rctx.file("BUILD.bazel", _expand_incompatible_template()) 82 return 83 84 info = json.decode(exec_result.stdout) 85 logger.info(lambda: _format_get_info_result(info)) 86 87 # NOTE: Keep in sync with recursive glob in define_local_runtime_toolchain_impl 88 repo_utils.watch_tree(rctx, rctx.path(info["include"])) 89 90 # The cc_library.includes values have to be non-absolute paths, otherwise 91 # the toolchain will give an error. Work around this error by making them 92 # appear as part of this repo. 93 rctx.symlink(info["include"], "include") 94 95 shared_lib_names = [ 96 info["PY3LIBRARY"], 97 info["LDLIBRARY"], 98 info["INSTSONAME"], 99 ] 100 101 # In some cases, the value may be empty. Not clear why. 102 shared_lib_names = [v for v in shared_lib_names if v] 103 104 # In some cases, the same value is returned for multiple keys. Not clear why. 105 shared_lib_names = {v: None for v in shared_lib_names}.keys() 106 shared_lib_dir = info["LIBDIR"] 107 108 # The specific files are symlinked instead of the whole directory 109 # because it can point to a directory that has more than just 110 # the Python runtime shared libraries, e.g. /usr/lib, or a Python 111 # specific directory with pip-installed shared libraries. 112 rctx.report_progress("Symlinking external Python shared libraries") 113 for name in shared_lib_names: 114 origin = rctx.path("{}/{}".format(shared_lib_dir, name)) 115 116 # The reported names don't always exist; it depends on the particulars 117 # of the runtime installation. 118 if origin.exists: 119 repo_utils.watch(rctx, origin) 120 rctx.symlink(origin, "lib/" + name) 121 122 rctx.file("WORKSPACE", "") 123 rctx.file("MODULE.bazel", "") 124 rctx.file("REPO.bazel", "") 125 rctx.file("BUILD.bazel", _TOOLCHAIN_IMPL_TEMPLATE.format( 126 major = info["major"], 127 minor = info["minor"], 128 micro = info["micro"], 129 interpreter_path = interpreter_path, 130 lib_ext = info["SHLIB_SUFFIX"], 131 implementation_name = info["implementation_name"], 132 os = "@platforms//os:{}".format(repo_utils.get_platforms_os_name(rctx)), 133 )) 134 135local_runtime_repo = repository_rule( 136 implementation = _local_runtime_repo_impl, 137 doc = """ 138Use a locally installed Python runtime as a toolchain implementation. 139 140Note this uses the runtime as a *platform runtime*. A platform runtime means 141means targets don't include the runtime itself as part of their runfiles or 142inputs. Instead, users must assure that where the targets run have the runtime 143pre-installed or otherwise available. 144 145This results in lighter weight binaries (in particular, Bazel doesn't have to 146create thousands of files for every `py_test`), at the risk of having to rely on 147a system having the necessary Python installed. 148""", 149 attrs = { 150 "interpreter_path": attr.string( 151 doc = """ 152An absolute path or program name on the `PATH` env var. 153 154Values with slashes are assumed to be the path to a program. Otherwise, it is 155treated as something to search for on `PATH` 156 157Note that, when a plain program name is used, the path to the interpreter is 158resolved at repository evalution time, not runtime of any resulting binaries. 159""", 160 default = "python3", 161 ), 162 "on_failure": attr.string( 163 default = _OnFailure.SKIP, 164 values = sorted(_OnFailure.__members__.values()), 165 doc = """ 166How to handle errors when trying to automatically determine settings. 167 168* `skip` will silently skip creating a runtime. Instead, a non-functional 169 runtime will be generated and marked as incompatible so it cannot be used. 170 This is best if a local runtime is known not to work or be available 171 in certain cases and that's OK. e.g., one use windows paths when there 172 are people running on linux. 173* `warn` will print a warning message. This is useful when you expect 174 a runtime to be available, but are OK with it missing and falling back 175 to some other runtime. 176* `fail` will result in a failure. This is only recommended if you must 177 ensure the runtime is available. 178""", 179 ), 180 "_get_local_runtime_info": attr.label( 181 allow_single_file = True, 182 default = "//python/private:get_local_runtime_info.py", 183 ), 184 "_rule_name": attr.string(default = "local_runtime_repo"), 185 }, 186 environ = ["PATH", REPO_DEBUG_ENV_VAR], 187) 188 189def _expand_incompatible_template(): 190 return _TOOLCHAIN_IMPL_TEMPLATE.format( 191 interpreter_path = "/incompatible", 192 implementation_name = "incompatible", 193 lib_ext = "incompatible", 194 major = "0", 195 minor = "0", 196 micro = "0", 197 os = "@platforms//:incompatible", 198 ) 199 200def _resolve_interpreter_path(rctx): 201 """Find the absolute path for an interpreter. 202 203 Args: 204 rctx: A repository_ctx object 205 206 Returns: 207 `struct` with the following fields: 208 * `resolved_path`: `path` object of a path that exists 209 * `describe_failure`: `Callable | None`. If a path that doesn't exist, 210 returns a description of why it couldn't be resolved 211 A path object or None. The path may not exist. 212 """ 213 if "/" not in rctx.attr.interpreter_path and "\\" not in rctx.attr.interpreter_path: 214 # Provide a bit nicer integration with pyenv: recalculate the runtime if the 215 # user changes the python version using e.g. `pyenv shell` 216 repo_utils.getenv(rctx, "PYENV_VERSION") 217 result = repo_utils.which_unchecked(rctx, rctx.attr.interpreter_path) 218 resolved_path = result.binary 219 describe_failure = result.describe_failure 220 else: 221 repo_utils.watch(rctx, rctx.attr.interpreter_path) 222 resolved_path = rctx.path(rctx.attr.interpreter_path) 223 if not resolved_path.exists: 224 describe_failure = lambda: "Path not found: {}".format(repr(rctx.attr.interpreter_path)) 225 else: 226 describe_failure = None 227 228 return struct( 229 resolved_path = resolved_path, 230 describe_failure = describe_failure, 231 ) 232 233def _format_get_info_result(info): 234 lines = ["GetPythonInfo result:"] 235 for key, value in sorted(info.items()): 236 lines.append(" {}: {}".format(key, value if value != "" else "<empty string>")) 237 return "\n".join(lines) 238