xref: /aosp_15_r20/external/bazelbuild-rules_python/python/private/python.bzl (revision 60517a1edbc8ecf509223e9af94a7adec7d736b8)
1# Copyright 2023 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"Python toolchain module extensions for use with bzlmod."
16
17load("@bazel_features//:features.bzl", "bazel_features")
18load("//python:versions.bzl", "DEFAULT_RELEASE_BASE_URL", "PLATFORMS", "TOOL_VERSIONS")
19load(":auth.bzl", "AUTH_ATTRS")
20load(":full_version.bzl", "full_version")
21load(":python_register_toolchains.bzl", "python_register_toolchains")
22load(":pythons_hub.bzl", "hub_repo")
23load(":repo_utils.bzl", "repo_utils")
24load(":semver.bzl", "semver")
25load(":text_util.bzl", "render")
26load(":toolchains_repo.bzl", "multi_toolchain_aliases")
27load(":util.bzl", "IS_BAZEL_6_4_OR_HIGHER")
28
29# This limit can be increased essentially arbitrarily, but doing so will cause a rebuild of all
30# targets using any of these toolchains due to the changed repository name.
31_MAX_NUM_TOOLCHAINS = 9999
32_TOOLCHAIN_INDEX_PAD_LENGTH = len(str(_MAX_NUM_TOOLCHAINS))
33
34def parse_modules(*, module_ctx, _fail = fail):
35    """Parse the modules and return a struct for registrations.
36
37    Args:
38        module_ctx: {type}`module_ctx` module context.
39        _fail: {type}`function` the failure function, mainly for testing.
40
41    Returns:
42        A struct with the following attributes:
43            * `toolchains`: The list of toolchains to register. The last
44              element is special and is treated as the default toolchain.
45            * `defaults`: The default `kwargs` passed to
46              {bzl:obj}`python_register_toolchains`.
47            * `debug_info`: {type}`None | dict` extra information to be passed
48              to the debug repo.
49    """
50    if module_ctx.os.environ.get("RULES_PYTHON_BZLMOD_DEBUG", "0") == "1":
51        debug_info = {
52            "toolchains_registered": [],
53        }
54    else:
55        debug_info = None
56
57    # The toolchain_info structs to register, in the order to register them in.
58    # NOTE: The last element is special: it is treated as the default toolchain,
59    # so there is special handling to ensure the last entry is the correct one.
60    toolchains = []
61
62    # We store the default toolchain separately to ensure it is the last
63    # toolchain added to toolchains.
64    # This is a toolchain_info struct.
65    default_toolchain = None
66
67    # Map of string Major.Minor or Major.Minor.Patch to the toolchain_info struct
68    global_toolchain_versions = {}
69
70    ignore_root_user_error = None
71
72    logger = repo_utils.logger(module_ctx, "python")
73
74    # if the root module does not register any toolchain then the
75    # ignore_root_user_error takes its default value: False
76    if not module_ctx.modules[0].tags.toolchain:
77        ignore_root_user_error = False
78
79    config = _get_toolchain_config(modules = module_ctx.modules, _fail = _fail)
80
81    seen_versions = {}
82    for mod in module_ctx.modules:
83        module_toolchain_versions = []
84        toolchain_attr_structs = _create_toolchain_attr_structs(
85            mod = mod,
86            seen_versions = seen_versions,
87            config = config,
88        )
89
90        for toolchain_attr in toolchain_attr_structs:
91            toolchain_version = toolchain_attr.python_version
92            toolchain_name = "python_" + toolchain_version.replace(".", "_")
93
94            # Duplicate versions within a module indicate a misconfigured module.
95            if toolchain_version in module_toolchain_versions:
96                _fail_duplicate_module_toolchain_version(toolchain_version, mod.name)
97            module_toolchain_versions.append(toolchain_version)
98
99            if mod.is_root:
100                # Only the root module and rules_python are allowed to specify the default
101                # toolchain for a couple reasons:
102                # * It prevents submodules from specifying different defaults and only
103                #   one of them winning.
104                # * rules_python needs to set a soft default in case the root module doesn't,
105                #   e.g. if the root module doesn't use Python itself.
106                # * The root module is allowed to override the rules_python default.
107                is_default = toolchain_attr.is_default
108
109                # Also only the root module should be able to decide ignore_root_user_error.
110                # Modules being depended upon don't know the final environment, so they aren't
111                # in the right position to know or decide what the correct setting is.
112
113                # If an inconsistency in the ignore_root_user_error among multiple toolchains is detected, fail.
114                if ignore_root_user_error != None and toolchain_attr.ignore_root_user_error != ignore_root_user_error:
115                    fail("Toolchains in the root module must have consistent 'ignore_root_user_error' attributes")
116
117                ignore_root_user_error = toolchain_attr.ignore_root_user_error
118            elif mod.name == "rules_python" and not default_toolchain:
119                # We don't do the len() check because we want the default that rules_python
120                # sets to be clearly visible.
121                is_default = toolchain_attr.is_default
122            else:
123                is_default = False
124
125            if is_default and default_toolchain != None:
126                _fail_multiple_default_toolchains(
127                    first = default_toolchain.name,
128                    second = toolchain_name,
129                )
130
131            # Ignore version collisions in the global scope because there isn't
132            # much else that can be done. Modules don't know and can't control
133            # what other modules do, so the first in the dependency graph wins.
134            if toolchain_version in global_toolchain_versions:
135                # If the python version is explicitly provided by the root
136                # module, they should not be warned for choosing the same
137                # version that rules_python provides as default.
138                first = global_toolchain_versions[toolchain_version]
139                if mod.name != "rules_python" or not first.module.is_root:
140                    # The warning can be enabled by setting the verbosity:
141                    # env RULES_PYTHON_REPO_DEBUG_VERBOSITY=INFO bazel build //...
142                    _warn_duplicate_global_toolchain_version(
143                        toolchain_version,
144                        first = first,
145                        second_toolchain_name = toolchain_name,
146                        second_module_name = mod.name,
147                        logger = logger,
148                    )
149                toolchain_info = None
150            else:
151                toolchain_info = struct(
152                    python_version = toolchain_attr.python_version,
153                    name = toolchain_name,
154                    register_coverage_tool = toolchain_attr.configure_coverage_tool,
155                    module = struct(name = mod.name, is_root = mod.is_root),
156                )
157                global_toolchain_versions[toolchain_version] = toolchain_info
158                if debug_info:
159                    debug_info["toolchains_registered"].append({
160                        "ignore_root_user_error": ignore_root_user_error,
161                        "module": {"is_root": mod.is_root, "name": mod.name},
162                        "name": toolchain_name,
163                    })
164
165            if is_default:
166                # This toolchain is setting the default, but the actual
167                # registration was performed previously, by a different module.
168                if toolchain_info == None:
169                    default_toolchain = global_toolchain_versions[toolchain_version]
170
171                    # Remove it because later code will add it at the end to
172                    # ensure it is last in the list.
173                    toolchains.remove(default_toolchain)
174                else:
175                    default_toolchain = toolchain_info
176            elif toolchain_info:
177                toolchains.append(toolchain_info)
178
179    config.default.setdefault("ignore_root_user_error", ignore_root_user_error)
180
181    # A default toolchain is required so that the non-version-specific rules
182    # are able to match a toolchain.
183    if default_toolchain == None:
184        fail("No default Python toolchain configured. Is rules_python missing `is_default=True`?")
185    elif default_toolchain.python_version not in global_toolchain_versions:
186        fail('Default version "{python_version}" selected by module ' +
187             '"{module_name}", but no toolchain with that version registered'.format(
188                 python_version = default_toolchain.python_version,
189                 module_name = default_toolchain.module.name,
190             ))
191
192    # The last toolchain in the BUILD file is set as the default
193    # toolchain. We need the default last.
194    toolchains.append(default_toolchain)
195
196    if len(toolchains) > _MAX_NUM_TOOLCHAINS:
197        fail("more than {} python versions are not supported".format(_MAX_NUM_TOOLCHAINS))
198
199    return struct(
200        config = config,
201        debug_info = debug_info,
202        default_python_version = toolchains[-1].python_version,
203        toolchains = [
204            struct(
205                python_version = t.python_version,
206                name = t.name,
207                register_coverage_tool = t.register_coverage_tool,
208            )
209            for t in toolchains
210        ],
211    )
212
213def _python_impl(module_ctx):
214    py = parse_modules(module_ctx = module_ctx)
215
216    for toolchain_info in py.toolchains:
217        # Ensure that we pass the full version here.
218        full_python_version = full_version(
219            version = toolchain_info.python_version,
220            minor_mapping = py.config.minor_mapping,
221        )
222        kwargs = {
223            "python_version": full_python_version,
224            "register_coverage_tool": toolchain_info.register_coverage_tool,
225        }
226
227        # Allow overrides per python version
228        kwargs.update(py.config.kwargs.get(toolchain_info.python_version, {}))
229        kwargs.update(py.config.kwargs.get(full_python_version, {}))
230        kwargs.update(py.config.default)
231        python_register_toolchains(name = toolchain_info.name, **kwargs)
232
233    # Create the pythons_hub repo for the interpreter meta data and the
234    # the various toolchains.
235    hub_repo(
236        name = "pythons_hub",
237        # Last toolchain is default
238        default_python_version = py.default_python_version,
239        toolchain_prefixes = [
240            render.toolchain_prefix(index, toolchain.name, _TOOLCHAIN_INDEX_PAD_LENGTH)
241            for index, toolchain in enumerate(py.toolchains)
242        ],
243        toolchain_python_versions = [
244            full_version(version = t.python_version, minor_mapping = py.config.minor_mapping)
245            for t in py.toolchains
246        ],
247        # The last toolchain is the default; it can't have version constraints
248        # Despite the implication of the arg name, the values are strs, not bools
249        toolchain_set_python_version_constraints = [
250            "True" if i != len(py.toolchains) - 1 else "False"
251            for i in range(len(py.toolchains))
252        ],
253        toolchain_user_repository_names = [t.name for t in py.toolchains],
254    )
255
256    # This is require in order to support multiple version py_test
257    # and py_binary
258    multi_toolchain_aliases(
259        name = "python_versions",
260        python_versions = {
261            toolchain.python_version: toolchain.name
262            for toolchain in py.toolchains
263        },
264    )
265
266    if py.debug_info != None:
267        _debug_repo(
268            name = "rules_python_bzlmod_debug",
269            debug_info = json.encode_indent(py.debug_info),
270        )
271
272    if bazel_features.external_deps.extension_metadata_has_reproducible:
273        return module_ctx.extension_metadata(reproducible = True)
274    else:
275        return None
276
277def _fail_duplicate_module_toolchain_version(version, module):
278    fail(("Duplicate module toolchain version: module '{module}' attempted " +
279          "to use version '{version}' multiple times in itself").format(
280        version = version,
281        module = module,
282    ))
283
284def _warn_duplicate_global_toolchain_version(version, first, second_toolchain_name, second_module_name, logger):
285    if not logger:
286        return
287
288    logger.info(lambda: (
289        "Ignoring toolchain '{second_toolchain}' from module '{second_module}': " +
290        "Toolchain '{first_toolchain}' from module '{first_module}' " +
291        "already registered Python version {version} and has precedence."
292    ).format(
293        first_toolchain = first.name,
294        first_module = first.module.name,
295        second_module = second_module_name,
296        second_toolchain = second_toolchain_name,
297        version = version,
298    ))
299
300def _fail_multiple_default_toolchains(first, second):
301    fail(("Multiple default toolchains: only one toolchain " +
302          "can have is_default=True. First default " +
303          "was toolchain '{first}'. Second was '{second}'").format(
304        first = first,
305        second = second,
306    ))
307
308def _validate_version(*, version, _fail = fail):
309    parsed = semver(version)
310    if parsed.patch == None or parsed.build or parsed.pre_release:
311        _fail("The 'python_version' attribute needs to specify an 'X.Y.Z' semver-compatible version, got: '{}'".format(version))
312        return False
313
314    return True
315
316def _process_single_version_overrides(*, tag, _fail = fail, default):
317    if not _validate_version(version = tag.python_version, _fail = _fail):
318        return
319
320    available_versions = default["tool_versions"]
321    kwargs = default.setdefault("kwargs", {})
322
323    if tag.sha256 or tag.urls:
324        if not (tag.sha256 and tag.urls):
325            _fail("Both `sha256` and `urls` overrides need to be provided together")
326            return
327
328        for platform in (tag.sha256 or []):
329            if platform not in PLATFORMS:
330                _fail("The platform must be one of {allowed} but got '{got}'".format(
331                    allowed = sorted(PLATFORMS),
332                    got = platform,
333                ))
334                return
335
336    sha256 = dict(tag.sha256) or available_versions[tag.python_version]["sha256"]
337    override = {
338        "sha256": sha256,
339        "strip_prefix": {
340            platform: tag.strip_prefix
341            for platform in sha256
342        },
343        "url": {
344            platform: list(tag.urls)
345            for platform in tag.sha256
346        } or available_versions[tag.python_version]["url"],
347    }
348
349    if tag.patches:
350        override["patch_strip"] = {
351            platform: tag.patch_strip
352            for platform in sha256
353        }
354        override["patches"] = {
355            platform: list(tag.patches)
356            for platform in sha256
357        }
358
359    available_versions[tag.python_version] = {k: v for k, v in override.items() if v}
360
361    if tag.distutils_content:
362        kwargs.setdefault(tag.python_version, {})["distutils_content"] = tag.distutils_content
363    if tag.distutils:
364        kwargs.setdefault(tag.python_version, {})["distutils"] = tag.distutils
365
366def _process_single_version_platform_overrides(*, tag, _fail = fail, default):
367    if not _validate_version(version = tag.python_version, _fail = _fail):
368        return
369
370    available_versions = default["tool_versions"]
371
372    if tag.python_version not in available_versions:
373        if not tag.urls or not tag.sha256 or not tag.strip_prefix:
374            _fail("When introducing a new python_version '{}', 'sha256', 'strip_prefix' and 'urls' must be specified".format(tag.python_version))
375            return
376        available_versions[tag.python_version] = {}
377
378    if tag.coverage_tool:
379        available_versions[tag.python_version].setdefault("coverage_tool", {})[tag.platform] = tag.coverage_tool
380    if tag.patch_strip:
381        available_versions[tag.python_version].setdefault("patch_strip", {})[tag.platform] = tag.patch_strip
382    if tag.patches:
383        available_versions[tag.python_version].setdefault("patches", {})[tag.platform] = list(tag.patches)
384    if tag.sha256:
385        available_versions[tag.python_version].setdefault("sha256", {})[tag.platform] = tag.sha256
386    if tag.strip_prefix:
387        available_versions[tag.python_version].setdefault("strip_prefix", {})[tag.platform] = tag.strip_prefix
388    if tag.urls:
389        available_versions[tag.python_version].setdefault("url", {})[tag.platform] = tag.urls
390
391def _process_global_overrides(*, tag, default, _fail = fail):
392    if tag.available_python_versions:
393        available_versions = default["tool_versions"]
394        all_versions = dict(available_versions)
395        available_versions.clear()
396        for v in tag.available_python_versions:
397            if v not in all_versions:
398                _fail("unknown version '{}', known versions are: {}".format(
399                    v,
400                    sorted(all_versions),
401                ))
402                return
403
404            available_versions[v] = all_versions[v]
405
406    if tag.minor_mapping:
407        for minor_version, full_version in tag.minor_mapping.items():
408            parsed = semver(minor_version)
409            if parsed.patch != None or parsed.build or parsed.pre_release:
410                fail("Expected the key to be of `X.Y` format but got `{}`".format(minor_version))
411            parsed = semver(full_version)
412            if parsed.patch == None:
413                fail("Expected the value to at least be of `X.Y.Z` format but got `{}`".format(minor_version))
414
415        default["minor_mapping"] = tag.minor_mapping
416
417    forwarded_attrs = sorted(AUTH_ATTRS) + [
418        "ignore_root_user_error",
419        "base_url",
420        "register_all_versions",
421    ]
422    for key in forwarded_attrs:
423        if getattr(tag, key, None):
424            default[key] = getattr(tag, key)
425
426def _override_defaults(*overrides, modules, _fail = fail, default):
427    mod = modules[0] if modules else None
428    if not mod or not mod.is_root:
429        return
430
431    overriden_keys = []
432
433    for override in overrides:
434        for tag in getattr(mod.tags, override.name):
435            key = override.key(tag)
436            if key not in overriden_keys:
437                overriden_keys.append(key)
438            elif key:
439                _fail("Only a single 'python.{}' can be present for '{}'".format(override.name, key))
440                return
441            else:
442                _fail("Only a single 'python.{}' can be present".format(override.name))
443                return
444
445            override.fn(tag = tag, _fail = _fail, default = default)
446
447def _get_toolchain_config(*, modules, _fail = fail):
448    # Items that can be overridden
449    available_versions = {
450        version: {
451            # Use a dicts straight away so that we could do URL overrides for a
452            # single version.
453            "sha256": dict(item["sha256"]),
454            "strip_prefix": {
455                platform: item["strip_prefix"]
456                for platform in item["sha256"]
457            },
458            "url": {
459                platform: [item["url"]]
460                for platform in item["sha256"]
461            },
462        }
463        for version, item in TOOL_VERSIONS.items()
464    }
465    default = {
466        "base_url": DEFAULT_RELEASE_BASE_URL,
467        "tool_versions": available_versions,
468    }
469
470    _override_defaults(
471        # First override by single version, because the sha256 will replace
472        # anything that has been there before.
473        struct(
474            name = "single_version_override",
475            key = lambda t: t.python_version,
476            fn = _process_single_version_overrides,
477        ),
478        # Then override particular platform entries if they need to be overridden.
479        struct(
480            name = "single_version_platform_override",
481            key = lambda t: (t.python_version, t.platform),
482            fn = _process_single_version_platform_overrides,
483        ),
484        # Then finally add global args and remove the unnecessary toolchains.
485        # This ensures that we can do further validations when removing.
486        struct(
487            name = "override",
488            key = lambda t: None,
489            fn = _process_global_overrides,
490        ),
491        modules = modules,
492        default = default,
493        _fail = _fail,
494    )
495
496    minor_mapping = default.pop("minor_mapping", {})
497    register_all_versions = default.pop("register_all_versions", False)
498    kwargs = default.pop("kwargs", {})
499
500    if not minor_mapping:
501        versions = {}
502        for version_string in available_versions:
503            v = semver(version_string)
504            versions.setdefault("{}.{}".format(v.major, v.minor), []).append((int(v.patch), version_string))
505
506        minor_mapping = {
507            major_minor: max(subset)[1]
508            for major_minor, subset in versions.items()
509        }
510
511    return struct(
512        kwargs = kwargs,
513        minor_mapping = minor_mapping,
514        default = default,
515        register_all_versions = register_all_versions,
516    )
517
518def _create_toolchain_attr_structs(*, mod, config, seen_versions):
519    arg_structs = []
520
521    for tag in mod.tags.toolchain:
522        arg_structs.append(_create_toolchain_attrs_struct(
523            tag = tag,
524            toolchain_tag_count = len(mod.tags.toolchain),
525        ))
526
527        seen_versions[tag.python_version] = True
528
529    if config.register_all_versions:
530        arg_structs.extend([
531            _create_toolchain_attrs_struct(python_version = v)
532            for v in config.default["tool_versions"].keys() + config.minor_mapping.keys()
533            if v not in seen_versions
534        ])
535
536    return arg_structs
537
538def _create_toolchain_attrs_struct(*, tag = None, python_version = None, toolchain_tag_count = None):
539    if tag and python_version:
540        fail("Only one of tag and python version can be specified")
541    if tag:
542        # A single toolchain is treated as the default because it's unambiguous.
543        is_default = tag.is_default or toolchain_tag_count == 1
544    else:
545        is_default = False
546
547    return struct(
548        is_default = is_default,
549        python_version = python_version if python_version else tag.python_version,
550        configure_coverage_tool = getattr(tag, "configure_coverage_tool", False),
551        ignore_root_user_error = getattr(tag, "ignore_root_user_error", False),
552    )
553
554def _get_bazel_version_specific_kwargs():
555    kwargs = {}
556
557    if IS_BAZEL_6_4_OR_HIGHER:
558        kwargs["environ"] = ["RULES_PYTHON_BZLMOD_DEBUG"]
559
560    return kwargs
561
562_toolchain = tag_class(
563    doc = """Tag class used to register Python toolchains.
564Use this tag class to register one or more Python toolchains. This class
565is also potentially called by sub modules. The following covers different
566business rules and use cases.
567
568:::{topic} Toolchains in the Root Module
569
570This class registers all toolchains in the root module.
571:::
572
573:::{topic} Toolchains in Sub Modules
574
575It will create a toolchain that is in a sub module, if the toolchain
576of the same name does not exist in the root module.  The extension stops name
577clashing between toolchains in the root module and toolchains in sub modules.
578You cannot configure more than one toolchain as the default toolchain.
579:::
580
581:::{topic} Toolchain set as the default version
582
583This extension will not create a toolchain that exists in a sub module,
584if the sub module toolchain is marked as the default version. If you have
585more than one toolchain in your root module, you need to set one of the
586toolchains as the default version.  If there is only one toolchain it
587is set as the default toolchain.
588:::
589
590:::{topic} Toolchain repository name
591
592A toolchain's repository name uses the format `python_{major}_{minor}`, e.g.
593`python_3_10`. The `major` and `minor` components are
594`major` and `minor` are the Python version from the `python_version` attribute.
595
596If a toolchain is registered in `X.Y.Z`, then similarly the toolchain name will
597be `python_{major}_{minor}_{patch}`, e.g. `python_3_10_19`.
598:::
599
600:::{topic} Toolchain detection
601The definition of the first toolchain wins, which means that the root module
602can override settings for any python toolchain available. This relies on the
603documented module traversal from the {obj}`module_ctx.modules`.
604:::
605
606:::{tip}
607In order to use a different name than the above, you can use the following `MODULE.bazel`
608syntax:
609```starlark
610python = use_extension("@rules_python//python/extensions:python.bzl", "python")
611python.toolchain(
612    is_default = True,
613    python_version = "3.11",
614)
615
616use_repo(python, my_python_name = "python_3_11")
617```
618
619Then the python interpreter will be available as `my_python_name`.
620:::
621""",
622    attrs = {
623        "configure_coverage_tool": attr.bool(
624            mandatory = False,
625            doc = "Whether or not to configure the default coverage tool provided by `rules_python` for the compatible toolchains.",
626        ),
627        "ignore_root_user_error": attr.bool(
628            default = False,
629            doc = """\
630If `False`, the Python runtime installation will be made read only. This improves
631the ability for Bazel to cache it, but prevents the interpreter from creating
632`.pyc` files for the standard library dynamically at runtime as they are loaded.
633
634If `True`, the Python runtime installation is read-write. This allows the
635interpreter to create `.pyc` files for the standard library, but, because they are
636created as needed, it adversely affects Bazel's ability to cache the runtime and
637can result in spurious build failures.
638""",
639            mandatory = False,
640        ),
641        "is_default": attr.bool(
642            mandatory = False,
643            doc = "Whether the toolchain is the default version",
644        ),
645        "python_version": attr.string(
646            mandatory = True,
647            doc = """\
648The Python version, in `major.minor` or `major.minor.patch` format, e.g
649`3.12` (or `3.12.3`), to create a toolchain for.
650""",
651        ),
652    },
653)
654
655_override = tag_class(
656    doc = """Tag class used to override defaults and behaviour of the module extension.
657
658:::{versionadded} 0.36.0
659:::
660""",
661    attrs = {
662        "available_python_versions": attr.string_list(
663            mandatory = False,
664            doc = """\
665The list of available python tool versions to use. Must be in `X.Y.Z` format.
666If the unknown version given the processing of the extension will fail - all of
667the versions in the list have to be defined with
668{obj}`python.single_version_override` or
669{obj}`python.single_version_platform_override` before they are used in this
670list.
671
672This attribute is usually used in order to ensure that no unexpected transitive
673dependencies are introduced.
674""",
675        ),
676        "base_url": attr.string(
677            mandatory = False,
678            doc = "The base URL to be used when downloading toolchains.",
679            default = DEFAULT_RELEASE_BASE_URL,
680        ),
681        "ignore_root_user_error": attr.bool(
682            default = False,
683            doc = """\
684If `False`, the Python runtime installation will be made read only. This improves
685the ability for Bazel to cache it, but prevents the interpreter from creating
686`.pyc` files for the standard library dynamically at runtime as they are loaded.
687
688If `True`, the Python runtime installation is read-write. This allows the
689interpreter to create `.pyc` files for the standard library, but, because they are
690created as needed, it adversely affects Bazel's ability to cache the runtime and
691can result in spurious build failures.
692""",
693            mandatory = False,
694        ),
695        "minor_mapping": attr.string_dict(
696            mandatory = False,
697            doc = """\
698The mapping between `X.Y` to `X.Y.Z` versions to be used when setting up
699toolchains. It defaults to the interpreter with the highest available patch
700version for each minor version. For example if one registers `3.10.3`, `3.10.4`
701and `3.11.4` then the default for the `minor_mapping` dict will be:
702```starlark
703{
704"3.10": "3.10.4",
705"3.11": "3.11.4",
706}
707```
708""",
709            default = {},
710        ),
711        "register_all_versions": attr.bool(default = False, doc = "Add all versions"),
712    } | AUTH_ATTRS,
713)
714
715_single_version_override = tag_class(
716    doc = """Override single python version URLs and patches for all platforms.
717
718:::{note}
719This will replace any existing configuration for the given python version.
720:::
721
722:::{tip}
723If you would like to modify the configuration for a specific `(version,
724platform)`, please use the {obj}`single_version_platform_override` tag
725class.
726:::
727
728:::{versionadded} 0.36.0
729:::
730""",
731    attrs = {
732        # NOTE @aignas 2024-09-01: all of the attributes except for `version`
733        # can be part of the `python.toolchain` call. That would make it more
734        # ergonomic to define new toolchains and to override values for old
735        # toolchains. The same semantics of the `first one wins` would apply,
736        # so technically there is no need for any overrides?
737        #
738        # Although these attributes would override the code that is used by the
739        # code in non-root modules, so technically this could be thought as
740        # being overridden.
741        #
742        # rules_go has a single download call:
743        # https://github.com/bazelbuild/rules_go/blob/master/go/private/extensions.bzl#L38
744        #
745        # However, we need to understand how to accommodate the fact that
746        # {attr}`single_version_override.version` only allows patch versions.
747        "distutils": attr.label(
748            allow_single_file = True,
749            doc = "A distutils.cfg file to be included in the Python installation. " +
750                  "Either {attr}`distutils` or {attr}`distutils_content` can be specified, but not both.",
751            mandatory = False,
752        ),
753        "distutils_content": attr.string(
754            doc = "A distutils.cfg file content to be included in the Python installation. " +
755                  "Either {attr}`distutils` or {attr}`distutils_content` can be specified, but not both.",
756            mandatory = False,
757        ),
758        "patch_strip": attr.int(
759            mandatory = False,
760            doc = "Same as the --strip argument of Unix patch.",
761            default = 0,
762        ),
763        "patches": attr.label_list(
764            mandatory = False,
765            doc = "A list of labels pointing to patch files to apply for the interpreter repository. They are applied in the list order and are applied before any platform-specific patches are applied.",
766        ),
767        "python_version": attr.string(
768            mandatory = True,
769            doc = "The python version to override URLs for. Must be in `X.Y.Z` format.",
770        ),
771        "sha256": attr.string_dict(
772            mandatory = False,
773            doc = "The python platform to sha256 dict. See {attr}`python.single_version_platform_override.platform` for allowed key values.",
774        ),
775        "strip_prefix": attr.string(
776            mandatory = False,
777            doc = "The 'strip_prefix' for the archive, defaults to 'python'.",
778            default = "python",
779        ),
780        "urls": attr.string_list(
781            mandatory = False,
782            doc = "The URL template to fetch releases for this Python version. See {attr}`python.single_version_platform_override.urls` for documentation.",
783        ),
784    },
785)
786
787_single_version_platform_override = tag_class(
788    doc = """Override single python version for a single existing platform.
789
790If the `(version, platform)` is new, we will add it to the existing versions and will
791use the same `url` template.
792
793:::{tip}
794If you would like to add or remove platforms to a single python version toolchain
795configuration, please use {obj}`single_version_override`.
796:::
797
798:::{versionadded} 0.36.0
799:::
800""",
801    attrs = {
802        "coverage_tool": attr.label(
803            doc = """\
804The coverage tool to be used for a particular Python interpreter. This can override
805`rules_python` defaults.
806""",
807        ),
808        "patch_strip": attr.int(
809            mandatory = False,
810            doc = "Same as the --strip argument of Unix patch.",
811            default = 0,
812        ),
813        "patches": attr.label_list(
814            mandatory = False,
815            doc = "A list of labels pointing to patch files to apply for the interpreter repository. They are applied in the list order and are applied after the common patches are applied.",
816        ),
817        "platform": attr.string(
818            mandatory = True,
819            values = PLATFORMS.keys(),
820            doc = "The platform to override the values for, must be one of:\n{}.".format("\n".join(sorted(["* `{}`".format(p) for p in PLATFORMS]))),
821        ),
822        "python_version": attr.string(
823            mandatory = True,
824            doc = "The python version to override URLs for. Must be in `X.Y.Z` format.",
825        ),
826        "sha256": attr.string(
827            mandatory = False,
828            doc = "The sha256 for the archive",
829        ),
830        "strip_prefix": attr.string(
831            mandatory = False,
832            doc = "The 'strip_prefix' for the archive, defaults to 'python'.",
833            default = "python",
834        ),
835        "urls": attr.string_list(
836            mandatory = False,
837            doc = "The URL template to fetch releases for this Python version. If the URL template results in a relative fragment, default base URL is going to be used. Occurrences of `{python_version}`, `{platform}` and `{build}` will be interpolated based on the contents in the override and the known {attr}`platform` values.",
838        ),
839    },
840)
841
842python = module_extension(
843    doc = """Bzlmod extension that is used to register Python toolchains.
844""",
845    implementation = _python_impl,
846    tag_classes = {
847        "override": _override,
848        "single_version_override": _single_version_override,
849        "single_version_platform_override": _single_version_platform_override,
850        "toolchain": _toolchain,
851    },
852    **_get_bazel_version_specific_kwargs()
853)
854
855_DEBUG_BUILD_CONTENT = """
856package(
857    default_visibility = ["//visibility:public"],
858)
859exports_files(["debug_info.json"])
860"""
861
862def _debug_repo_impl(repo_ctx):
863    repo_ctx.file("BUILD.bazel", _DEBUG_BUILD_CONTENT)
864    repo_ctx.file("debug_info.json", repo_ctx.attr.debug_info)
865
866_debug_repo = repository_rule(
867    implementation = _debug_repo_impl,
868    attrs = {
869        "debug_info": attr.string(),
870    },
871)
872