xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/local_runtime_repo.bzl (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
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