| # Copyright 2023 The Bazel Authors. All rights reserved. | |
| # | |
| # Licensed under the Apache License, Version 2.0 (the "License"); | |
| # you may not use this file except in compliance with the License. | |
| # You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, software | |
| # distributed under the License is distributed on an "AS IS" BASIS, | |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| # See the License for the specific language governing permissions and | |
| # limitations under the License. | |
| "pip module extension for use with bzlmod" | |
| load("@pythons_hub//:interpreters.bzl", "DEFAULT_PYTHON_VERSION", "INTERPRETER_LABELS") | |
| load("@rules_python//python:pip.bzl", "whl_library_alias") | |
| load( | |
| "@rules_python//python/pip_install:pip_repository.bzl", | |
| "locked_requirements_label", | |
| "pip_hub_repository_bzlmod", | |
| "pip_repository_attrs", | |
| "pip_repository_bzlmod", | |
| "use_isolated", | |
| "whl_library", | |
| ) | |
| load("@rules_python//python/pip_install:requirements_parser.bzl", parse_requirements = "parse") | |
| load("//python/private:normalize_name.bzl", "normalize_name") | |
| load("//python/private:version_label.bzl", "version_label") | |
| def _whl_mods_impl(mctx): | |
| """Implementation of the pip.whl_mods tag class. | |
| This creates the JSON files used to modify the creation of different wheels. | |
| """ | |
| whl_mods_dict = {} | |
| for mod in mctx.modules: | |
| for whl_mod_attr in mod.tags.whl_mods: | |
| if whl_mod_attr.hub_name not in whl_mods_dict.keys(): | |
| whl_mods_dict[whl_mod_attr.hub_name] = {whl_mod_attr.whl_name: whl_mod_attr} | |
| elif whl_mod_attr.whl_name in whl_mods_dict[whl_mod_attr.hub_name].keys(): | |
| # We cannot have the same wheel name in the same hub, as we | |
| # will create the same JSON file name. | |
| fail("""\ | |
| Found same whl_name '{}' in the same hub '{}', please use a different hub_name.""".format( | |
| whl_mod_attr.whl_name, | |
| whl_mod_attr.hub_name, | |
| )) | |
| else: | |
| whl_mods_dict[whl_mod_attr.hub_name][whl_mod_attr.whl_name] = whl_mod_attr | |
| for hub_name, whl_maps in whl_mods_dict.items(): | |
| whl_mods = {} | |
| # create a struct that we can pass to the _whl_mods_repo rule | |
| # to create the different JSON files. | |
| for whl_name, mods in whl_maps.items(): | |
| build_content = mods.additive_build_content | |
| if mods.additive_build_content_file != None and mods.additive_build_content != "": | |
| fail("""\ | |
| You cannot use both the additive_build_content and additive_build_content_file arguments at the same time. | |
| """) | |
| elif mods.additive_build_content_file != None: | |
| build_content = mctx.read(mods.additive_build_content_file) | |
| whl_mods[whl_name] = json.encode(struct( | |
| additive_build_content = build_content, | |
| copy_files = mods.copy_files, | |
| copy_executables = mods.copy_executables, | |
| data = mods.data, | |
| data_exclude_glob = mods.data_exclude_glob, | |
| srcs_exclude_glob = mods.srcs_exclude_glob, | |
| )) | |
| _whl_mods_repo( | |
| name = hub_name, | |
| whl_mods = whl_mods, | |
| ) | |
| def _create_versioned_pip_and_whl_repos(module_ctx, pip_attr, whl_map): | |
| python_interpreter_target = pip_attr.python_interpreter_target | |
| # if we do not have the python_interpreter set in the attributes | |
| # we programtically find it. | |
| hub_name = pip_attr.hub_name | |
| if python_interpreter_target == None: | |
| python_name = "python_" + version_label(pip_attr.python_version, sep = "_") | |
| if python_name not in INTERPRETER_LABELS.keys(): | |
| fail(( | |
| "Unable to find interpreter for pip hub '{hub_name}' for " + | |
| "python_version={version}: Make sure a corresponding " + | |
| '`python.toolchain(python_version="{version}")` call exists' | |
| ).format( | |
| hub_name = hub_name, | |
| version = pip_attr.python_version, | |
| )) | |
| python_interpreter_target = INTERPRETER_LABELS[python_name] | |
| pip_name = "{}_{}".format( | |
| hub_name, | |
| version_label(pip_attr.python_version), | |
| ) | |
| requrements_lock = locked_requirements_label(module_ctx, pip_attr) | |
| # Parse the requirements file directly in starlark to get the information | |
| # needed for the whl_libary declarations below. This is needed to contain | |
| # the pip_repository logic to a single module extension. | |
| requirements_lock_content = module_ctx.read(requrements_lock) | |
| parse_result = parse_requirements(requirements_lock_content) | |
| requirements = parse_result.requirements | |
| extra_pip_args = pip_attr.extra_pip_args + parse_result.options | |
| # Create the repository where users load the `requirement` macro. Under bzlmod | |
| # this does not create the install_deps() macro. | |
| # TODO: we may not need this repository once we have entry points | |
| # supported. For now a user can access this repository and use | |
| # the entrypoint functionality. | |
| pip_repository_bzlmod( | |
| name = pip_name, | |
| repo_name = pip_name, | |
| requirements_lock = pip_attr.requirements_lock, | |
| ) | |
| if hub_name not in whl_map: | |
| whl_map[hub_name] = {} | |
| whl_modifications = {} | |
| if pip_attr.whl_modifications != None: | |
| for mod, whl_name in pip_attr.whl_modifications.items(): | |
| whl_modifications[whl_name] = mod | |
| # Create a new wheel library for each of the different whls | |
| for whl_name, requirement_line in requirements: | |
| # We are not using the "sanitized name" because the user | |
| # would need to guess what name we modified the whl name | |
| # to. | |
| annotation = whl_modifications.get(whl_name) | |
| whl_name = normalize_name(whl_name) | |
| whl_library( | |
| name = "%s_%s" % (pip_name, whl_name), | |
| requirement = requirement_line, | |
| repo = pip_name, | |
| repo_prefix = pip_name + "_", | |
| annotation = annotation, | |
| python_interpreter = pip_attr.python_interpreter, | |
| python_interpreter_target = python_interpreter_target, | |
| quiet = pip_attr.quiet, | |
| timeout = pip_attr.timeout, | |
| isolated = use_isolated(module_ctx, pip_attr), | |
| extra_pip_args = extra_pip_args, | |
| download_only = pip_attr.download_only, | |
| pip_data_exclude = pip_attr.pip_data_exclude, | |
| enable_implicit_namespace_pkgs = pip_attr.enable_implicit_namespace_pkgs, | |
| environment = pip_attr.environment, | |
| ) | |
| if whl_name not in whl_map[hub_name]: | |
| whl_map[hub_name][whl_name] = {} | |
| whl_map[hub_name][whl_name][pip_attr.python_version] = pip_name + "_" | |
| def _pip_impl(module_ctx): | |
| """Implementation of a class tag that creates the pip hub(s) and corresponding pip spoke, alias and whl repositories. | |
| This implmentation iterates through all of the `pip.parse` calls and creates | |
| different pip hub repositories based on the "hub_name". Each of the | |
| pip calls create spoke repos that uses a specific Python interpreter. | |
| In a MODULES.bazel file we have: | |
| pip.parse( | |
| hub_name = "pip", | |
| python_version = 3.9, | |
| requirements_lock = "//:requirements_lock_3_9.txt", | |
| requirements_windows = "//:requirements_windows_3_9.txt", | |
| ) | |
| pip.parse( | |
| hub_name = "pip", | |
| python_version = 3.10, | |
| requirements_lock = "//:requirements_lock_3_10.txt", | |
| requirements_windows = "//:requirements_windows_3_10.txt", | |
| ) | |
| For instance, we have a hub with the name of "pip". | |
| A repository named the following is created. It is actually called last when | |
| all of the pip spokes are collected. | |
| - @@rules_python~override~pip~pip | |
| As shown in the example code above we have the following. | |
| Two different pip.parse statements exist in MODULE.bazel provide the hub_name "pip". | |
| These definitions create two different pip spoke repositories that are | |
| related to the hub "pip". | |
| One spoke uses Python 3.9 and the other uses Python 3.10. This code automatically | |
| determines the Python version and the interpreter. | |
| Both of these pip spokes contain requirements files that includes websocket | |
| and its dependencies. | |
| Two different repositories are created for the two spokes: | |
| - @@rules_python~override~pip~pip_39 | |
| - @@rules_python~override~pip~pip_310 | |
| The different spoke names are a combination of the hub_name and the Python version. | |
| In the future we may remove this repository, but we do not support entry points. | |
| yet, and that functionality exists in these repos. | |
| We also need repositories for the wheels that the different pip spokes contain. | |
| For each Python version a different wheel repository is created. In our example | |
| each pip spoke had a requirments file that contained websockets. We | |
| then create two different wheel repositories that are named the following. | |
| - @@rules_python~override~pip~pip_39_websockets | |
| - @@rules_python~override~pip~pip_310_websockets | |
| And if the wheel has any other dependies subsequest wheels are created in the same fashion. | |
| We also create a repository for the wheel alias. We want to just use the syntax | |
| 'requirement("websockets")' we need to have an alias repository that is named: | |
| - @@rules_python~override~pip~pip_websockets | |
| This repository contains alias statements for the different wheel components (pkg, data, etc). | |
| Each of those aliases has a select that resolves to a spoke repository depending on | |
| the Python version. | |
| Also we may have more than one hub as defined in a MODULES.bazel file. So we could have multiple | |
| hubs pointing to various different pip spokes. | |
| Some other business rules notes. A hub can only have one spoke per Python version. We cannot | |
| have a hub named "pip" that has two spokes that use the Python 3.9 interpreter. Second | |
| we cannot have the same hub name used in submodules. The hub name has to be globally | |
| unique. | |
| This implementation reuses elements of non-bzlmod code and also reuses the first implementation | |
| of pip bzlmod, but adds the capability to have multiple pip.parse calls. | |
| This implementation also handles the creation of whl_modification JSON files that are used | |
| during the creation of wheel libraries. These JSON files used via the annotations argument | |
| when calling wheel_installer.py. | |
| Args: | |
| module_ctx: module contents | |
| """ | |
| # Build all of the wheel modifications if the tag class is called. | |
| _whl_mods_impl(module_ctx) | |
| # Used to track all the different pip hubs and the spoke pip Python | |
| # versions. | |
| pip_hub_map = {} | |
| # Keeps track of all the hub's whl repos across the different versions. | |
| # dict[hub, dict[whl, dict[version, str pip]]] | |
| # Where hub, whl, and pip are the repo names | |
| hub_whl_map = {} | |
| for mod in module_ctx.modules: | |
| for pip_attr in mod.tags.parse: | |
| hub_name = pip_attr.hub_name | |
| if hub_name in pip_hub_map: | |
| # We cannot have two hubs with the same name in different | |
| # modules. | |
| if pip_hub_map[hub_name].module_name != mod.name: | |
| fail(( | |
| "Duplicate cross-module pip hub named '{hub}': pip hub " + | |
| "names must be unique across modules. First defined " + | |
| "by module '{first_module}', second attempted by " + | |
| "module '{second_module}'" | |
| ).format( | |
| hub = hub_name, | |
| first_module = pip_hub_map[hub_name].module_name, | |
| second_module = mod.name, | |
| )) | |
| if pip_attr.python_version in pip_hub_map[hub_name].python_versions: | |
| fail(( | |
| "Duplicate pip python version '{version}' for hub " + | |
| "'{hub}' in module '{module}': the Python versions " + | |
| "used for a hub must be unique" | |
| ).format( | |
| hub = hub_name, | |
| module = mod.name, | |
| version = pip_attr.python_version, | |
| )) | |
| else: | |
| pip_hub_map[pip_attr.hub_name].python_versions.append(pip_attr.python_version) | |
| else: | |
| pip_hub_map[pip_attr.hub_name] = struct( | |
| module_name = mod.name, | |
| python_versions = [pip_attr.python_version], | |
| ) | |
| _create_versioned_pip_and_whl_repos(module_ctx, pip_attr, hub_whl_map) | |
| for hub_name, whl_map in hub_whl_map.items(): | |
| for whl_name, version_map in whl_map.items(): | |
| if DEFAULT_PYTHON_VERSION not in version_map: | |
| fail(( | |
| "Default python version '{version}' missing in pip " + | |
| "hub '{hub}': update your pip.parse() calls so that " + | |
| 'includes `python_version = "{version}"`' | |
| ).format( | |
| version = DEFAULT_PYTHON_VERSION, | |
| hub = hub_name, | |
| )) | |
| # Create the alias repositories which contains different select | |
| # statements These select statements point to the different pip | |
| # whls that are based on a specific version of Python. | |
| whl_library_alias( | |
| name = hub_name + "_" + whl_name, | |
| wheel_name = whl_name, | |
| default_version = DEFAULT_PYTHON_VERSION, | |
| version_map = version_map, | |
| ) | |
| # Create the hub repository for pip. | |
| pip_hub_repository_bzlmod( | |
| name = hub_name, | |
| repo_name = hub_name, | |
| whl_library_alias_names = whl_map.keys(), | |
| ) | |
| def _pip_parse_ext_attrs(): | |
| attrs = dict({ | |
| "hub_name": attr.string( | |
| mandatory = True, | |
| doc = """ | |
| The name of the repo pip dependencies will be accessible from. | |
| This name must be unique between modules; unless your module is guaranteed to | |
| always be the root module, it's highly recommended to include your module name | |
| in the hub name. Repo mapping, `use_repo(..., pip="my_modules_pip_deps")`, can | |
| be used for shorter local names within your module. | |
| Within a module, the same `hub_name` can be specified to group different Python | |
| versions of pip dependencies under one repository name. This allows using a | |
| Python version-agnostic name when referring to pip dependencies; the | |
| correct version will be automatically selected. | |
| Typically, a module will only have a single hub of pip dependencies, but this | |
| is not required. Each hub is a separate resolution of pip dependencies. This | |
| means if different programs need different versions of some library, separate | |
| hubs can be created, and each program can use its respective hub's targets. | |
| Targets from different hubs should not be used together. | |
| """, | |
| ), | |
| "python_version": attr.string( | |
| default = DEFAULT_PYTHON_VERSION, | |
| doc = """ | |
| The Python version to use for resolving the pip dependencies. If not specified, | |
| then the default Python version (as set by the root module or rules_python) | |
| will be used. | |
| The version specified here must have a corresponding `python.toolchain()` | |
| configured. This attribute defaults to the version of the toolchain | |
| that is set as the default Python version. Or if only one toolchain | |
| is used, this attribute defaults to that version of Python. | |
| """, | |
| ), | |
| "whl_modifications": attr.label_keyed_string_dict( | |
| mandatory = False, | |
| doc = """\ | |
| A dict of labels to wheel names that is typically generated by the whl_modifications. | |
| The labels are JSON config files describing the modifications. | |
| """, | |
| ), | |
| }, **pip_repository_attrs) | |
| # Like the pip_repository rule, we end up setting this manually so | |
| # don't allow users to override it. | |
| attrs.pop("repo_prefix") | |
| # incompatible_generate_aliases is always True in bzlmod | |
| attrs.pop("incompatible_generate_aliases") | |
| return attrs | |
| def _whl_mod_attrs(): | |
| attrs = { | |
| "additive_build_content": attr.string( | |
| doc = "(str, optional): Raw text to add to the generated `BUILD` file of a package.", | |
| ), | |
| "additive_build_content_file": attr.label( | |
| doc = """\ | |
| (label, optional): path to a BUILD file to add to the generated | |
| `BUILD` file of a package. You cannot use both additive_build_content and additive_build_content_file | |
| arguments at the same time.""", | |
| ), | |
| "copy_executables": attr.string_dict( | |
| doc = """\ | |
| (dict, optional): A mapping of `src` and `out` files for | |
| [@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as | |
| executable.""", | |
| ), | |
| "copy_files": attr.string_dict( | |
| doc = """\ | |
| (dict, optional): A mapping of `src` and `out` files for | |
| [@bazel_skylib//rules:copy_file.bzl][cf]""", | |
| ), | |
| "data": attr.string_list( | |
| doc = """\ | |
| (list, optional): A list of labels to add as `data` dependencies to | |
| the generated `py_library` target.""", | |
| ), | |
| "data_exclude_glob": attr.string_list( | |
| doc = """\ | |
| (list, optional): A list of exclude glob patterns to add as `data` to | |
| the generated `py_library` target.""", | |
| ), | |
| "hub_name": attr.string( | |
| doc = """\ | |
| Name of the whl modification, hub we use this name to set the modifications for | |
| pip.parse. If you have different pip hubs you can use a different name, | |
| otherwise it is best practice to just use one. | |
| You cannot have the same `hub_name` in different modules. You can reuse the same | |
| name in the same module for different wheels that you put in the same hub, but you | |
| cannot have a child module that uses the same `hub_name`. | |
| """, | |
| mandatory = True, | |
| ), | |
| "srcs_exclude_glob": attr.string_list( | |
| doc = """\ | |
| (list, optional): A list of labels to add as `srcs` to the generated | |
| `py_library` target.""", | |
| ), | |
| "whl_name": attr.string( | |
| doc = "The whl name that the modifications are used for.", | |
| mandatory = True, | |
| ), | |
| } | |
| return attrs | |
| pip = module_extension( | |
| doc = """\ | |
| This extension is used to make dependencies from pip available. | |
| pip.parse: | |
| To use, call `pip.parse()` and specify `hub_name` and your requirements file. | |
| Dependencies will be downloaded and made available in a repo named after the | |
| `hub_name` argument. | |
| Each `pip.parse()` call configures a particular Python version. Multiple calls | |
| can be made to configure different Python versions, and will be grouped by | |
| the `hub_name` argument. This allows the same logical name, e.g. `@pip//numpy` | |
| to automatically resolve to different, Python version-specific, libraries. | |
| pip.whl_mods: | |
| This tag class is used to help create JSON files to describe modifications to | |
| the BUILD files for wheels. | |
| """, | |
| implementation = _pip_impl, | |
| tag_classes = { | |
| "parse": tag_class( | |
| attrs = _pip_parse_ext_attrs(), | |
| doc = """\ | |
| This tag class is used to create a pip hub and all of the spokes that are part of that hub. | |
| This tag class reuses most of the pip attributes that are found in | |
| @rules_python//python/pip_install:pip_repository.bzl. | |
| The exceptions are it does not use the args 'repo_prefix', | |
| and 'incompatible_generate_aliases'. We set the repository prefix | |
| for the user and the alias arg is always True in bzlmod. | |
| """, | |
| ), | |
| "whl_mods": tag_class( | |
| attrs = _whl_mod_attrs(), | |
| doc = """\ | |
| This tag class is used to create JSON file that are used when calling wheel_builder.py. These | |
| JSON files contain instructions on how to modify a wheel's project. Each of the attributes | |
| create different modifications based on the type of attribute. Previously to bzlmod these | |
| JSON files where referred to as annotations, and were renamed to whl_modifications in this | |
| extension. | |
| """, | |
| ), | |
| }, | |
| ) | |
| def _whl_mods_repo_impl(rctx): | |
| rctx.file("BUILD.bazel", "") | |
| for whl_name, mods in rctx.attr.whl_mods.items(): | |
| rctx.file("{}.json".format(whl_name), mods) | |
| _whl_mods_repo = repository_rule( | |
| doc = """\ | |
| This rule creates json files based on the whl_mods attribute. | |
| """, | |
| implementation = _whl_mods_repo_impl, | |
| attrs = { | |
| "whl_mods": attr.string_dict( | |
| mandatory = True, | |
| doc = "JSON endcoded string that is provided to wheel_builder.py", | |
| ), | |
| }, | |
| ) |