xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/common/py_runtime_rule.bzl (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
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"""Implementation of py_runtime rule."""
15
16load("@bazel_skylib//lib:dicts.bzl", "dicts")
17load("@bazel_skylib//lib:paths.bzl", "paths")
18load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
19load("//python/private:reexports.bzl", "BuiltinPyRuntimeInfo")
20load("//python/private:util.bzl", "IS_BAZEL_7_OR_HIGHER")
21load(":attributes.bzl", "NATIVE_RULES_ALLOWLIST_ATTRS")
22load(":providers.bzl", "DEFAULT_BOOTSTRAP_TEMPLATE", "DEFAULT_STUB_SHEBANG", "PyRuntimeInfo")
23load(":py_internal.bzl", "py_internal")
24
25_py_builtins = py_internal
26
27def _py_runtime_impl(ctx):
28    interpreter_path = ctx.attr.interpreter_path or None  # Convert empty string to None
29    interpreter = ctx.attr.interpreter
30    if (interpreter_path and interpreter) or (not interpreter_path and not interpreter):
31        fail("exactly one of the 'interpreter' or 'interpreter_path' attributes must be specified")
32
33    runtime_files = depset(transitive = [
34        t[DefaultInfo].files
35        for t in ctx.attr.files
36    ])
37
38    runfiles = ctx.runfiles()
39
40    hermetic = bool(interpreter)
41    if not hermetic:
42        if runtime_files:
43            fail("if 'interpreter_path' is given then 'files' must be empty")
44        if not paths.is_absolute(interpreter_path):
45            fail("interpreter_path must be an absolute path")
46    else:
47        interpreter_di = interpreter[DefaultInfo]
48
49        if interpreter_di.files_to_run and interpreter_di.files_to_run.executable:
50            interpreter = interpreter_di.files_to_run.executable
51            runfiles = runfiles.merge(interpreter_di.default_runfiles)
52
53            runtime_files = depset(transitive = [
54                interpreter_di.files,
55                interpreter_di.default_runfiles.files,
56                runtime_files,
57            ])
58        elif _is_singleton_depset(interpreter_di.files):
59            interpreter = interpreter_di.files.to_list()[0]
60        else:
61            fail("interpreter must be an executable target or must produce exactly one file.")
62
63    if ctx.attr.coverage_tool:
64        coverage_di = ctx.attr.coverage_tool[DefaultInfo]
65
66        if _is_singleton_depset(coverage_di.files):
67            coverage_tool = coverage_di.files.to_list()[0]
68        elif coverage_di.files_to_run and coverage_di.files_to_run.executable:
69            coverage_tool = coverage_di.files_to_run.executable
70        else:
71            fail("coverage_tool must be an executable target or must produce exactly one file.")
72
73        coverage_files = depset(transitive = [
74            coverage_di.files,
75            coverage_di.default_runfiles.files,
76        ])
77    else:
78        coverage_tool = None
79        coverage_files = None
80
81    python_version = ctx.attr.python_version
82
83    interpreter_version_info = ctx.attr.interpreter_version_info
84    if not interpreter_version_info:
85        python_version_flag = ctx.attr._python_version_flag[BuildSettingInfo].value
86        if python_version_flag:
87            interpreter_version_info = _interpreter_version_info_from_version_str(python_version_flag)
88
89    # TODO: Uncomment this after --incompatible_python_disable_py2 defaults to true
90    # if ctx.fragments.py.disable_py2 and python_version == "PY2":
91    #     fail("Using Python 2 is not supported and disabled; see " +
92    #          "https://github.com/bazelbuild/bazel/issues/15684")
93
94    pyc_tag = ctx.attr.pyc_tag
95    if not pyc_tag and (ctx.attr.implementation_name and
96                        interpreter_version_info.get("major") and
97                        interpreter_version_info.get("minor")):
98        pyc_tag = "{}-{}{}".format(
99            ctx.attr.implementation_name,
100            interpreter_version_info["major"],
101            interpreter_version_info["minor"],
102        )
103
104    py_runtime_info_kwargs = dict(
105        interpreter_path = interpreter_path or None,
106        interpreter = interpreter,
107        files = runtime_files if hermetic else None,
108        coverage_tool = coverage_tool,
109        coverage_files = coverage_files,
110        python_version = python_version,
111        stub_shebang = ctx.attr.stub_shebang,
112        bootstrap_template = ctx.file.bootstrap_template,
113    )
114    builtin_py_runtime_info_kwargs = dict(py_runtime_info_kwargs)
115
116    # There are all args that BuiltinPyRuntimeInfo doesn't support
117    py_runtime_info_kwargs.update(dict(
118        implementation_name = ctx.attr.implementation_name,
119        interpreter_version_info = interpreter_version_info,
120        pyc_tag = pyc_tag,
121        stage2_bootstrap_template = ctx.file.stage2_bootstrap_template,
122        zip_main_template = ctx.file.zip_main_template,
123    ))
124
125    if not IS_BAZEL_7_OR_HIGHER:
126        builtin_py_runtime_info_kwargs.pop("bootstrap_template")
127
128    return [
129        PyRuntimeInfo(**py_runtime_info_kwargs),
130        # Return the builtin provider for better compatibility.
131        # 1. There is a legacy code path in py_binary that
132        #    checks for the provider when toolchains aren't used
133        # 2. It makes it easier to transition from builtins to rules_python
134        BuiltinPyRuntimeInfo(**builtin_py_runtime_info_kwargs),
135        DefaultInfo(
136            files = runtime_files,
137            runfiles = runfiles,
138        ),
139    ]
140
141# Bind to the name "py_runtime" to preserve the kind/rule_class it shows up
142# as elsewhere.
143py_runtime = rule(
144    implementation = _py_runtime_impl,
145    doc = """
146Represents a Python runtime used to execute Python code.
147
148A `py_runtime` target can represent either a *platform runtime* or an *in-build
149runtime*. A platform runtime accesses a system-installed interpreter at a known
150path, whereas an in-build runtime points to an executable target that acts as
151the interpreter. In both cases, an "interpreter" means any executable binary or
152wrapper script that is capable of running a Python script passed on the command
153line, following the same conventions as the standard CPython interpreter.
154
155A platform runtime is by its nature non-hermetic. It imposes a requirement on
156the target platform to have an interpreter located at a specific path. An
157in-build runtime may or may not be hermetic, depending on whether it points to
158a checked-in interpreter or a wrapper script that accesses the system
159interpreter.
160
161Example
162
163```
164load("@rules_python//python:py_runtime.bzl", "py_runtime")
165
166py_runtime(
167    name = "python-2.7.12",
168    files = glob(["python-2.7.12/**"]),
169    interpreter = "python-2.7.12/bin/python",
170)
171
172py_runtime(
173    name = "python-3.6.0",
174    interpreter_path = "/opt/pyenv/versions/3.6.0/bin/python",
175)
176```
177""",
178    fragments = ["py"],
179    attrs = dicts.add(NATIVE_RULES_ALLOWLIST_ATTRS, {
180        "bootstrap_template": attr.label(
181            allow_single_file = True,
182            default = DEFAULT_BOOTSTRAP_TEMPLATE,
183            doc = """
184The bootstrap script template file to use. Should have %python_binary%,
185%workspace_name%, %main%, and %imports%.
186
187This template, after expansion, becomes the executable file used to start the
188process, so it is responsible for initial bootstrapping actions such as finding
189the Python interpreter, runfiles, and constructing an environment to run the
190intended Python application.
191
192While this attribute is currently optional, it will become required when the
193Python rules are moved out of Bazel itself.
194
195The exact variable names expanded is an unstable API and is subject to change.
196The API will become more stable when the Python rules are moved out of Bazel
197itself.
198
199See @bazel_tools//tools/python:python_bootstrap_template.txt for more variables.
200""",
201        ),
202        "coverage_tool": attr.label(
203            allow_files = False,
204            doc = """
205This is a target to use for collecting code coverage information from
206{rule}`py_binary` and {rule}`py_test` targets.
207
208If set, the target must either produce a single file or be an executable target.
209The path to the single file, or the executable if the target is executable,
210determines the entry point for the python coverage tool.  The target and its
211runfiles will be added to the runfiles when coverage is enabled.
212
213The entry point for the tool must be loadable by a Python interpreter (e.g. a
214`.py` or `.pyc` file).  It must accept the command line arguments
215of [`coverage.py`](https://coverage.readthedocs.io), at least including
216the `run` and `lcov` subcommands.
217""",
218        ),
219        "files": attr.label_list(
220            allow_files = True,
221            doc = """
222For an in-build runtime, this is the set of files comprising this runtime.
223These files will be added to the runfiles of Python binaries that use this
224runtime. For a platform runtime this attribute must not be set.
225""",
226        ),
227        "implementation_name": attr.string(
228            doc = "The Python implementation name (`sys.implementation.name`)",
229        ),
230        "interpreter": attr.label(
231            # We set `allow_files = True` to allow specifying executable
232            # targets from rules that have more than one default output,
233            # e.g. sh_binary.
234            allow_files = True,
235            doc = """
236For an in-build runtime, this is the target to invoke as the interpreter. It
237can be either of:
238
239* A single file, which will be the interpreter binary. It's assumed such
240  interpreters are either self-contained single-file executables or any
241  supporting files are specified in `files`.
242* An executable target. The target's executable will be the interpreter binary.
243  Any other default outputs (`target.files`) and plain files runfiles
244  (`runfiles.files`) will be automatically included as if specified in the
245  `files` attribute.
246
247  NOTE: the runfiles of the target may not yet be properly respected/propagated
248  to consumers of the toolchain/interpreter, see
249  bazelbuild/rules_python/issues/1612
250
251For a platform runtime (i.e. `interpreter_path` being set) this attribute must
252not be set.
253""",
254        ),
255        "interpreter_path": attr.string(doc = """
256For a platform runtime, this is the absolute path of a Python interpreter on
257the target platform. For an in-build runtime this attribute must not be set.
258"""),
259        "interpreter_version_info": attr.string_dict(
260            doc = """
261Version information about the interpreter this runtime provides.
262
263If not specified, uses {obj}`--python_version`
264
265The supported keys match the names for `sys.version_info`. While the input
266values are strings, most are converted to ints. The supported keys are:
267  * major: int, the major version number
268  * minor: int, the minor version number
269  * micro: optional int, the micro version number
270  * releaselevel: optional str, the release level
271  * serial: optional int, the serial number of the release
272
273:::{versionchanged} 0.36.0
274{obj}`--python_version` determines the default value.
275:::
276""",
277            mandatory = False,
278        ),
279        "pyc_tag": attr.string(
280            doc = """
281Optional string; the tag portion of a pyc filename, e.g. the `cpython-39` infix
282of `foo.cpython-39.pyc`. See PEP 3147. If not specified, it will be computed
283from `implementation_name` and `interpreter_version_info`. If no pyc_tag is
284available, then only source-less pyc generation will function correctly.
285""",
286        ),
287        "python_version": attr.string(
288            default = "PY3",
289            values = ["PY2", "PY3"],
290            doc = """
291Whether this runtime is for Python major version 2 or 3. Valid values are `"PY2"`
292and `"PY3"`.
293
294The default value is controlled by the `--incompatible_py3_is_default` flag.
295However, in the future this attribute will be mandatory and have no default
296value.
297            """,
298        ),
299        "stage2_bootstrap_template": attr.label(
300            default = "//python/private:stage2_bootstrap_template",
301            allow_single_file = True,
302            doc = """
303The template to use when two stage bootstrapping is enabled
304
305:::{seealso}
306{obj}`PyRuntimeInfo.stage2_bootstrap_template` and {obj}`--bootstrap_impl`
307:::
308""",
309        ),
310        "stub_shebang": attr.string(
311            default = DEFAULT_STUB_SHEBANG,
312            doc = """
313"Shebang" expression prepended to the bootstrapping Python stub script
314used when executing {rule}`py_binary` targets.
315
316See https://github.com/bazelbuild/bazel/issues/8685 for
317motivation.
318
319Does not apply to Windows.
320""",
321        ),
322        "zip_main_template": attr.label(
323            default = "//python/private:zip_main_template",
324            allow_single_file = True,
325            doc = """
326The template to use for a zip's top-level `__main__.py` file.
327
328This becomes the entry point executed when `python foo.zip` is run.
329
330:::{seealso}
331The {obj}`PyRuntimeInfo.zip_main_template` field.
332:::
333""",
334        ),
335        "_python_version_flag": attr.label(
336            default = "//python/config_settings:python_version",
337        ),
338    }),
339)
340
341def _is_singleton_depset(files):
342    # Bazel 6 doesn't have this helper to optimize detecting singleton depsets.
343    if _py_builtins:
344        return _py_builtins.is_singleton_depset(files)
345    else:
346        return len(files.to_list()) == 1
347
348def _interpreter_version_info_from_version_str(version_str):
349    parts = version_str.split(".")
350    version_info = {}
351    for key in ("major", "minor", "micro"):
352        if not parts:
353            break
354        version_info[key] = parts.pop(0)
355
356    return version_info
357