xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/toolchains_repo.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
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