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"pip module extension for use with bzlmod" 16 17load("@pythons_hub//:interpreters.bzl", "DEFAULT_PYTHON_VERSION", "INTERPRETER_LABELS") 18load("//python:pip.bzl", "whl_library_alias") 19load( 20 "//python/pip_install:pip_repository.bzl", 21 "locked_requirements_label", 22 "pip_hub_repository_bzlmod", 23 "pip_repository_attrs", 24 "pip_repository_bzlmod", 25 "use_isolated", 26 "whl_library", 27) 28load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse") 29load("//python/private:normalize_name.bzl", "normalize_name") 30load("//python/private:version_label.bzl", "version_label") 31 32def _whl_mods_impl(mctx): 33 """Implementation of the pip.whl_mods tag class. 34 35 This creates the JSON files used to modify the creation of different wheels. 36""" 37 whl_mods_dict = {} 38 for mod in mctx.modules: 39 for whl_mod_attr in mod.tags.whl_mods: 40 if whl_mod_attr.hub_name not in whl_mods_dict.keys(): 41 whl_mods_dict[whl_mod_attr.hub_name] = {whl_mod_attr.whl_name: whl_mod_attr} 42 elif whl_mod_attr.whl_name in whl_mods_dict[whl_mod_attr.hub_name].keys(): 43 # We cannot have the same wheel name in the same hub, as we 44 # will create the same JSON file name. 45 fail("""\ 46Found same whl_name '{}' in the same hub '{}', please use a different hub_name.""".format( 47 whl_mod_attr.whl_name, 48 whl_mod_attr.hub_name, 49 )) 50 else: 51 whl_mods_dict[whl_mod_attr.hub_name][whl_mod_attr.whl_name] = whl_mod_attr 52 53 for hub_name, whl_maps in whl_mods_dict.items(): 54 whl_mods = {} 55 56 # create a struct that we can pass to the _whl_mods_repo rule 57 # to create the different JSON files. 58 for whl_name, mods in whl_maps.items(): 59 build_content = mods.additive_build_content 60 if mods.additive_build_content_file != None and mods.additive_build_content != "": 61 fail("""\ 62You cannot use both the additive_build_content and additive_build_content_file arguments at the same time. 63""") 64 elif mods.additive_build_content_file != None: 65 build_content = mctx.read(mods.additive_build_content_file) 66 67 whl_mods[whl_name] = json.encode(struct( 68 additive_build_content = build_content, 69 copy_files = mods.copy_files, 70 copy_executables = mods.copy_executables, 71 data = mods.data, 72 data_exclude_glob = mods.data_exclude_glob, 73 srcs_exclude_glob = mods.srcs_exclude_glob, 74 )) 75 76 _whl_mods_repo( 77 name = hub_name, 78 whl_mods = whl_mods, 79 ) 80 81def _create_versioned_pip_and_whl_repos(module_ctx, pip_attr, whl_map): 82 python_interpreter_target = pip_attr.python_interpreter_target 83 84 # if we do not have the python_interpreter set in the attributes 85 # we programtically find it. 86 hub_name = pip_attr.hub_name 87 if python_interpreter_target == None: 88 python_name = "python_" + version_label(pip_attr.python_version, sep = "_") 89 if python_name not in INTERPRETER_LABELS.keys(): 90 fail(( 91 "Unable to find interpreter for pip hub '{hub_name}' for " + 92 "python_version={version}: Make sure a corresponding " + 93 '`python.toolchain(python_version="{version}")` call exists' 94 ).format( 95 hub_name = hub_name, 96 version = pip_attr.python_version, 97 )) 98 python_interpreter_target = INTERPRETER_LABELS[python_name] 99 100 pip_name = "{}_{}".format( 101 hub_name, 102 version_label(pip_attr.python_version), 103 ) 104 requrements_lock = locked_requirements_label(module_ctx, pip_attr) 105 106 # Parse the requirements file directly in starlark to get the information 107 # needed for the whl_libary declarations below. This is needed to contain 108 # the pip_repository logic to a single module extension. 109 requirements_lock_content = module_ctx.read(requrements_lock) 110 parse_result = parse_requirements(requirements_lock_content) 111 requirements = parse_result.requirements 112 extra_pip_args = pip_attr.extra_pip_args + parse_result.options 113 114 # Create the repository where users load the `requirement` macro. Under bzlmod 115 # this does not create the install_deps() macro. 116 # TODO: we may not need this repository once we have entry points 117 # supported. For now a user can access this repository and use 118 # the entrypoint functionality. 119 pip_repository_bzlmod( 120 name = pip_name, 121 repo_name = pip_name, 122 requirements_lock = pip_attr.requirements_lock, 123 ) 124 if hub_name not in whl_map: 125 whl_map[hub_name] = {} 126 127 whl_modifications = {} 128 if pip_attr.whl_modifications != None: 129 for mod, whl_name in pip_attr.whl_modifications.items(): 130 whl_modifications[whl_name] = mod 131 132 # Create a new wheel library for each of the different whls 133 for whl_name, requirement_line in requirements: 134 # We are not using the "sanitized name" because the user 135 # would need to guess what name we modified the whl name 136 # to. 137 annotation = whl_modifications.get(whl_name) 138 whl_name = normalize_name(whl_name) 139 whl_library( 140 name = "%s_%s" % (pip_name, whl_name), 141 requirement = requirement_line, 142 repo = pip_name, 143 repo_prefix = pip_name + "_", 144 annotation = annotation, 145 python_interpreter = pip_attr.python_interpreter, 146 python_interpreter_target = python_interpreter_target, 147 quiet = pip_attr.quiet, 148 timeout = pip_attr.timeout, 149 isolated = use_isolated(module_ctx, pip_attr), 150 extra_pip_args = extra_pip_args, 151 download_only = pip_attr.download_only, 152 pip_data_exclude = pip_attr.pip_data_exclude, 153 enable_implicit_namespace_pkgs = pip_attr.enable_implicit_namespace_pkgs, 154 environment = pip_attr.environment, 155 ) 156 157 if whl_name not in whl_map[hub_name]: 158 whl_map[hub_name][whl_name] = {} 159 160 whl_map[hub_name][whl_name][pip_attr.python_version] = pip_name + "_" 161 162def _pip_impl(module_ctx): 163 """Implementation of a class tag that creates the pip hub(s) and corresponding pip spoke, alias and whl repositories. 164 165 This implmentation iterates through all of the `pip.parse` calls and creates 166 different pip hub repositories based on the "hub_name". Each of the 167 pip calls create spoke repos that uses a specific Python interpreter. 168 169 In a MODULES.bazel file we have: 170 171 pip.parse( 172 hub_name = "pip", 173 python_version = 3.9, 174 requirements_lock = "//:requirements_lock_3_9.txt", 175 requirements_windows = "//:requirements_windows_3_9.txt", 176 ) 177 pip.parse( 178 hub_name = "pip", 179 python_version = 3.10, 180 requirements_lock = "//:requirements_lock_3_10.txt", 181 requirements_windows = "//:requirements_windows_3_10.txt", 182 ) 183 184 For instance, we have a hub with the name of "pip". 185 A repository named the following is created. It is actually called last when 186 all of the pip spokes are collected. 187 188 - @@rules_python~override~pip~pip 189 190 As shown in the example code above we have the following. 191 Two different pip.parse statements exist in MODULE.bazel provide the hub_name "pip". 192 These definitions create two different pip spoke repositories that are 193 related to the hub "pip". 194 One spoke uses Python 3.9 and the other uses Python 3.10. This code automatically 195 determines the Python version and the interpreter. 196 Both of these pip spokes contain requirements files that includes websocket 197 and its dependencies. 198 199 Two different repositories are created for the two spokes: 200 201 - @@rules_python~override~pip~pip_39 202 - @@rules_python~override~pip~pip_310 203 204 The different spoke names are a combination of the hub_name and the Python version. 205 In the future we may remove this repository, but we do not support entry points. 206 yet, and that functionality exists in these repos. 207 208 We also need repositories for the wheels that the different pip spokes contain. 209 For each Python version a different wheel repository is created. In our example 210 each pip spoke had a requirments file that contained websockets. We 211 then create two different wheel repositories that are named the following. 212 213 - @@rules_python~override~pip~pip_39_websockets 214 - @@rules_python~override~pip~pip_310_websockets 215 216 And if the wheel has any other dependies subsequest wheels are created in the same fashion. 217 218 We also create a repository for the wheel alias. We want to just use the syntax 219 'requirement("websockets")' we need to have an alias repository that is named: 220 221 - @@rules_python~override~pip~pip_websockets 222 223 This repository contains alias statements for the different wheel components (pkg, data, etc). 224 Each of those aliases has a select that resolves to a spoke repository depending on 225 the Python version. 226 227 Also we may have more than one hub as defined in a MODULES.bazel file. So we could have multiple 228 hubs pointing to various different pip spokes. 229 230 Some other business rules notes. A hub can only have one spoke per Python version. We cannot 231 have a hub named "pip" that has two spokes that use the Python 3.9 interpreter. Second 232 we cannot have the same hub name used in submodules. The hub name has to be globally 233 unique. 234 235 This implementation reuses elements of non-bzlmod code and also reuses the first implementation 236 of pip bzlmod, but adds the capability to have multiple pip.parse calls. 237 238 This implementation also handles the creation of whl_modification JSON files that are used 239 during the creation of wheel libraries. These JSON files used via the annotations argument 240 when calling wheel_installer.py. 241 242 Args: 243 module_ctx: module contents 244 245 """ 246 247 # Build all of the wheel modifications if the tag class is called. 248 _whl_mods_impl(module_ctx) 249 250 # Used to track all the different pip hubs and the spoke pip Python 251 # versions. 252 pip_hub_map = {} 253 254 # Keeps track of all the hub's whl repos across the different versions. 255 # dict[hub, dict[whl, dict[version, str pip]]] 256 # Where hub, whl, and pip are the repo names 257 hub_whl_map = {} 258 259 for mod in module_ctx.modules: 260 for pip_attr in mod.tags.parse: 261 hub_name = pip_attr.hub_name 262 if hub_name in pip_hub_map: 263 # We cannot have two hubs with the same name in different 264 # modules. 265 if pip_hub_map[hub_name].module_name != mod.name: 266 fail(( 267 "Duplicate cross-module pip hub named '{hub}': pip hub " + 268 "names must be unique across modules. First defined " + 269 "by module '{first_module}', second attempted by " + 270 "module '{second_module}'" 271 ).format( 272 hub = hub_name, 273 first_module = pip_hub_map[hub_name].module_name, 274 second_module = mod.name, 275 )) 276 277 if pip_attr.python_version in pip_hub_map[hub_name].python_versions: 278 fail(( 279 "Duplicate pip python version '{version}' for hub " + 280 "'{hub}' in module '{module}': the Python versions " + 281 "used for a hub must be unique" 282 ).format( 283 hub = hub_name, 284 module = mod.name, 285 version = pip_attr.python_version, 286 )) 287 else: 288 pip_hub_map[pip_attr.hub_name].python_versions.append(pip_attr.python_version) 289 else: 290 pip_hub_map[pip_attr.hub_name] = struct( 291 module_name = mod.name, 292 python_versions = [pip_attr.python_version], 293 ) 294 295 _create_versioned_pip_and_whl_repos(module_ctx, pip_attr, hub_whl_map) 296 297 for hub_name, whl_map in hub_whl_map.items(): 298 for whl_name, version_map in whl_map.items(): 299 if DEFAULT_PYTHON_VERSION in version_map: 300 whl_default_version = DEFAULT_PYTHON_VERSION 301 else: 302 whl_default_version = None 303 304 # Create the alias repositories which contains different select 305 # statements These select statements point to the different pip 306 # whls that are based on a specific version of Python. 307 whl_library_alias( 308 name = hub_name + "_" + whl_name, 309 wheel_name = whl_name, 310 default_version = whl_default_version, 311 version_map = version_map, 312 ) 313 314 # Create the hub repository for pip. 315 pip_hub_repository_bzlmod( 316 name = hub_name, 317 repo_name = hub_name, 318 whl_library_alias_names = whl_map.keys(), 319 ) 320 321def _pip_parse_ext_attrs(): 322 attrs = dict({ 323 "hub_name": attr.string( 324 mandatory = True, 325 doc = """ 326The name of the repo pip dependencies will be accessible from. 327 328This name must be unique between modules; unless your module is guaranteed to 329always be the root module, it's highly recommended to include your module name 330in the hub name. Repo mapping, `use_repo(..., pip="my_modules_pip_deps")`, can 331be used for shorter local names within your module. 332 333Within a module, the same `hub_name` can be specified to group different Python 334versions of pip dependencies under one repository name. This allows using a 335Python version-agnostic name when referring to pip dependencies; the 336correct version will be automatically selected. 337 338Typically, a module will only have a single hub of pip dependencies, but this 339is not required. Each hub is a separate resolution of pip dependencies. This 340means if different programs need different versions of some library, separate 341hubs can be created, and each program can use its respective hub's targets. 342Targets from different hubs should not be used together. 343""", 344 ), 345 "python_version": attr.string( 346 mandatory = True, 347 doc = """ 348The Python version to use for resolving the pip dependencies, in Major.Minor 349format (e.g. "3.11"). Patch level granularity (e.g. "3.11.1") is not supported. 350If not specified, then the default Python version (as set by the root module or 351rules_python) will be used. 352 353The version specified here must have a corresponding `python.toolchain()` 354configured. 355""", 356 ), 357 "whl_modifications": attr.label_keyed_string_dict( 358 mandatory = False, 359 doc = """\ 360A dict of labels to wheel names that is typically generated by the whl_modifications. 361The labels are JSON config files describing the modifications. 362""", 363 ), 364 }, **pip_repository_attrs) 365 366 # Like the pip_repository rule, we end up setting this manually so 367 # don't allow users to override it. 368 attrs.pop("repo_prefix") 369 370 # incompatible_generate_aliases is always True in bzlmod 371 attrs.pop("incompatible_generate_aliases") 372 373 return attrs 374 375def _whl_mod_attrs(): 376 attrs = { 377 "additive_build_content": attr.string( 378 doc = "(str, optional): Raw text to add to the generated `BUILD` file of a package.", 379 ), 380 "additive_build_content_file": attr.label( 381 doc = """\ 382(label, optional): path to a BUILD file to add to the generated 383`BUILD` file of a package. You cannot use both additive_build_content and additive_build_content_file 384arguments at the same time.""", 385 ), 386 "copy_executables": attr.string_dict( 387 doc = """\ 388(dict, optional): A mapping of `src` and `out` files for 389[@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as 390executable.""", 391 ), 392 "copy_files": attr.string_dict( 393 doc = """\ 394(dict, optional): A mapping of `src` and `out` files for 395[@bazel_skylib//rules:copy_file.bzl][cf]""", 396 ), 397 "data": attr.string_list( 398 doc = """\ 399(list, optional): A list of labels to add as `data` dependencies to 400the generated `py_library` target.""", 401 ), 402 "data_exclude_glob": attr.string_list( 403 doc = """\ 404(list, optional): A list of exclude glob patterns to add as `data` to 405the generated `py_library` target.""", 406 ), 407 "hub_name": attr.string( 408 doc = """\ 409Name of the whl modification, hub we use this name to set the modifications for 410pip.parse. If you have different pip hubs you can use a different name, 411otherwise it is best practice to just use one. 412 413You cannot have the same `hub_name` in different modules. You can reuse the same 414name in the same module for different wheels that you put in the same hub, but you 415cannot have a child module that uses the same `hub_name`. 416""", 417 mandatory = True, 418 ), 419 "srcs_exclude_glob": attr.string_list( 420 doc = """\ 421(list, optional): A list of labels to add as `srcs` to the generated 422`py_library` target.""", 423 ), 424 "whl_name": attr.string( 425 doc = "The whl name that the modifications are used for.", 426 mandatory = True, 427 ), 428 } 429 return attrs 430 431pip = module_extension( 432 doc = """\ 433This extension is used to make dependencies from pip available. 434 435pip.parse: 436To use, call `pip.parse()` and specify `hub_name` and your requirements file. 437Dependencies will be downloaded and made available in a repo named after the 438`hub_name` argument. 439 440Each `pip.parse()` call configures a particular Python version. Multiple calls 441can be made to configure different Python versions, and will be grouped by 442the `hub_name` argument. This allows the same logical name, e.g. `@pip//numpy` 443to automatically resolve to different, Python version-specific, libraries. 444 445pip.whl_mods: 446This tag class is used to help create JSON files to describe modifications to 447the BUILD files for wheels. 448""", 449 implementation = _pip_impl, 450 tag_classes = { 451 "parse": tag_class( 452 attrs = _pip_parse_ext_attrs(), 453 doc = """\ 454This tag class is used to create a pip hub and all of the spokes that are part of that hub. 455This tag class reuses most of the pip attributes that are found in 456@rules_python//python/pip_install:pip_repository.bzl. 457The exceptions are it does not use the args 'repo_prefix', 458and 'incompatible_generate_aliases'. We set the repository prefix 459for the user and the alias arg is always True in bzlmod. 460""", 461 ), 462 "whl_mods": tag_class( 463 attrs = _whl_mod_attrs(), 464 doc = """\ 465This tag class is used to create JSON file that are used when calling wheel_builder.py. These 466JSON files contain instructions on how to modify a wheel's project. Each of the attributes 467create different modifications based on the type of attribute. Previously to bzlmod these 468JSON files where referred to as annotations, and were renamed to whl_modifications in this 469extension. 470""", 471 ), 472 }, 473) 474 475def _whl_mods_repo_impl(rctx): 476 rctx.file("BUILD.bazel", "") 477 for whl_name, mods in rctx.attr.whl_mods.items(): 478 rctx.file("{}.json".format(whl_name), mods) 479 480_whl_mods_repo = repository_rule( 481 doc = """\ 482This rule creates json files based on the whl_mods attribute. 483""", 484 implementation = _whl_mods_repo_impl, 485 attrs = { 486 "whl_mods": attr.string_dict( 487 mandatory = True, 488 doc = "JSON endcoded string that is provided to wheel_builder.py", 489 ), 490 }, 491) 492