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"" 16 17load("//python:repositories.bzl", "get_interpreter_dirname", "is_standalone_interpreter") 18load("//python:versions.bzl", "WINDOWS_NAME") 19load("//python/pip_install:repositories.bzl", "all_requirements") 20load("//python/pip_install:requirements_parser.bzl", parse_requirements = "parse") 21load("//python/pip_install/private:generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel") 22load("//python/pip_install/private:srcs.bzl", "PIP_INSTALL_PY_SRCS") 23load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") 24load("//python/private:normalize_name.bzl", "normalize_name") 25load("//python/private:render_pkg_aliases.bzl", "render_pkg_aliases") 26load("//python/private:toolchains_repo.bzl", "get_host_os_arch") 27load("//python/private:which.bzl", "which_with_fail") 28 29CPPFLAGS = "CPPFLAGS" 30 31COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools" 32 33_WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point" 34 35def _construct_pypath(rctx): 36 """Helper function to construct a PYTHONPATH. 37 38 Contains entries for code in this repo as well as packages downloaded from //python/pip_install:repositories.bzl. 39 This allows us to run python code inside repository rule implementations. 40 41 Args: 42 rctx: Handle to the repository_context. 43 Returns: String of the PYTHONPATH. 44 """ 45 46 # Get the root directory of these rules 47 rules_root = rctx.path(Label("//:BUILD.bazel")).dirname 48 thirdparty_roots = [ 49 # Includes all the external dependencies from repositories.bzl 50 rctx.path(Label("@" + repo + "//:BUILD.bazel")).dirname 51 for repo in all_requirements 52 ] 53 separator = ":" if not "windows" in rctx.os.name.lower() else ";" 54 pypath = separator.join([str(p) for p in [rules_root] + thirdparty_roots]) 55 return pypath 56 57def _get_python_interpreter_attr(rctx): 58 """A helper function for getting the `python_interpreter` attribute or it's default 59 60 Args: 61 rctx (repository_ctx): Handle to the rule repository context. 62 63 Returns: 64 str: The attribute value or it's default 65 """ 66 if rctx.attr.python_interpreter: 67 return rctx.attr.python_interpreter 68 69 if "win" in rctx.os.name: 70 return "python.exe" 71 else: 72 return "python3" 73 74def _resolve_python_interpreter(rctx): 75 """Helper function to find the python interpreter from the common attributes 76 77 Args: 78 rctx: Handle to the rule repository context. 79 Returns: Python interpreter path. 80 """ 81 python_interpreter = _get_python_interpreter_attr(rctx) 82 83 if rctx.attr.python_interpreter_target != None: 84 python_interpreter = rctx.path(rctx.attr.python_interpreter_target) 85 86 if BZLMOD_ENABLED: 87 (os, _) = get_host_os_arch(rctx) 88 89 # On Windows, the symlink doesn't work because Windows attempts to find 90 # Python DLLs where the symlink is, not where the symlink points. 91 if os == WINDOWS_NAME: 92 python_interpreter = python_interpreter.realpath 93 elif "/" not in python_interpreter: 94 found_python_interpreter = rctx.which(python_interpreter) 95 if not found_python_interpreter: 96 fail("python interpreter `{}` not found in PATH".format(python_interpreter)) 97 python_interpreter = found_python_interpreter 98 return python_interpreter 99 100def _get_xcode_location_cflags(rctx): 101 """Query the xcode sdk location to update cflags 102 103 Figure out if this interpreter target comes from rules_python, and patch the xcode sdk location if so. 104 Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg 105 otherwise. See https://github.com/indygreg/python-build-standalone/issues/103 106 """ 107 108 # Only run on MacOS hosts 109 if not rctx.os.name.lower().startswith("mac os"): 110 return [] 111 112 xcode_sdk_location = rctx.execute([which_with_fail("xcode-select", rctx), "--print-path"]) 113 if xcode_sdk_location.return_code != 0: 114 return [] 115 116 xcode_root = xcode_sdk_location.stdout.strip() 117 if COMMAND_LINE_TOOLS_PATH_SLUG not in xcode_root.lower(): 118 # This is a full xcode installation somewhere like /Applications/Xcode13.0.app/Contents/Developer 119 # so we need to change the path to to the macos specific tools which are in a different relative 120 # path than xcode installed command line tools. 121 xcode_root = "{}/Platforms/MacOSX.platform/Developer".format(xcode_root) 122 return [ 123 "-isysroot {}/SDKs/MacOSX.sdk".format(xcode_root), 124 ] 125 126def _get_toolchain_unix_cflags(rctx): 127 """Gather cflags from a standalone toolchain for unix systems. 128 129 Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg 130 otherwise. See https://github.com/indygreg/python-build-standalone/issues/103 131 """ 132 133 # Only run on Unix systems 134 if not rctx.os.name.lower().startswith(("mac os", "linux")): 135 return [] 136 137 # Only update the location when using a standalone toolchain. 138 if not is_standalone_interpreter(rctx, rctx.attr.python_interpreter_target): 139 return [] 140 141 er = rctx.execute([ 142 rctx.path(rctx.attr.python_interpreter_target).realpath, 143 "-c", 144 "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}', end='')", 145 ]) 146 if er.return_code != 0: 147 fail("could not get python version from interpreter (status {}): {}".format(er.return_code, er.stderr)) 148 _python_version = er.stdout 149 include_path = "{}/include/python{}".format( 150 get_interpreter_dirname(rctx, rctx.attr.python_interpreter_target), 151 _python_version, 152 ) 153 154 return ["-isystem {}".format(include_path)] 155 156def use_isolated(ctx, attr): 157 """Determine whether or not to pass the pip `--isolated` flag to the pip invocation. 158 159 Args: 160 ctx: repository or module context 161 attr: attributes for the repo rule or tag extension 162 163 Returns: 164 True if --isolated should be passed 165 """ 166 use_isolated = attr.isolated 167 168 # The environment variable will take precedence over the attribute 169 isolated_env = ctx.os.environ.get("RULES_PYTHON_PIP_ISOLATED", None) 170 if isolated_env != None: 171 if isolated_env.lower() in ("0", "false"): 172 use_isolated = False 173 else: 174 use_isolated = True 175 176 return use_isolated 177 178def _parse_optional_attrs(rctx, args): 179 """Helper function to parse common attributes of pip_repository and whl_library repository rules. 180 181 This function also serializes the structured arguments as JSON 182 so they can be passed on the command line to subprocesses. 183 184 Args: 185 rctx: Handle to the rule repository context. 186 args: A list of parsed args for the rule. 187 Returns: Augmented args list. 188 """ 189 190 if use_isolated(rctx, rctx.attr): 191 args.append("--isolated") 192 193 # Check for None so we use empty default types from our attrs. 194 # Some args want to be list, and some want to be dict. 195 if rctx.attr.extra_pip_args != None: 196 args += [ 197 "--extra_pip_args", 198 json.encode(struct(arg = rctx.attr.extra_pip_args)), 199 ] 200 201 if rctx.attr.download_only: 202 args.append("--download_only") 203 204 if rctx.attr.pip_data_exclude != None: 205 args += [ 206 "--pip_data_exclude", 207 json.encode(struct(arg = rctx.attr.pip_data_exclude)), 208 ] 209 210 if rctx.attr.enable_implicit_namespace_pkgs: 211 args.append("--enable_implicit_namespace_pkgs") 212 213 if rctx.attr.environment != None: 214 args += [ 215 "--environment", 216 json.encode(struct(arg = rctx.attr.environment)), 217 ] 218 219 return args 220 221def _create_repository_execution_environment(rctx): 222 """Create a environment dictionary for processes we spawn with rctx.execute. 223 224 Args: 225 rctx: The repository context. 226 Returns: 227 Dictionary of environment variable suitable to pass to rctx.execute. 228 """ 229 230 # Gather any available CPPFLAGS values 231 cppflags = [] 232 cppflags.extend(_get_xcode_location_cflags(rctx)) 233 cppflags.extend(_get_toolchain_unix_cflags(rctx)) 234 235 env = { 236 "PYTHONPATH": _construct_pypath(rctx), 237 CPPFLAGS: " ".join(cppflags), 238 } 239 240 return env 241 242_BUILD_FILE_CONTENTS = """\ 243package(default_visibility = ["//visibility:public"]) 244 245# Ensure the `requirements.bzl` source can be accessed by stardoc, since users load() from it 246exports_files(["requirements.bzl"]) 247""" 248 249def locked_requirements_label(ctx, attr): 250 """Get the preferred label for a locked requirements file based on platform. 251 252 Args: 253 ctx: repository or module context 254 attr: attributes for the repo rule or tag extension 255 256 Returns: 257 Label 258 """ 259 os = ctx.os.name.lower() 260 requirements_txt = attr.requirements_lock 261 if os.startswith("mac os") and attr.requirements_darwin != None: 262 requirements_txt = attr.requirements_darwin 263 elif os.startswith("linux") and attr.requirements_linux != None: 264 requirements_txt = attr.requirements_linux 265 elif "win" in os and attr.requirements_windows != None: 266 requirements_txt = attr.requirements_windows 267 if not requirements_txt: 268 fail("""\ 269A requirements_lock attribute must be specified, or a platform-specific lockfile using one of the requirements_* attributes. 270""") 271 return requirements_txt 272 273def _create_pip_repository_bzlmod(rctx, bzl_packages, requirements): 274 repo_name = rctx.attr.repo_name 275 build_contents = _BUILD_FILE_CONTENTS 276 aliases = render_pkg_aliases(repo_name = repo_name, bzl_packages = bzl_packages) 277 for path, contents in aliases.items(): 278 rctx.file(path, contents) 279 280 # NOTE: we are using the canonical name with the double '@' in order to 281 # always uniquely identify a repository, as the labels are being passed as 282 # a string and the resolution of the label happens at the call-site of the 283 # `requirement`, et al. macros. 284 macro_tmpl = "@@{name}//{{}}:{{}}".format(name = rctx.attr.name) 285 286 rctx.file("BUILD.bazel", build_contents) 287 rctx.template("requirements.bzl", rctx.attr._template, substitutions = { 288 "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([ 289 macro_tmpl.format(p, "data") 290 for p in bzl_packages 291 ]), 292 "%%ALL_REQUIREMENTS%%": _format_repr_list([ 293 macro_tmpl.format(p, p) 294 for p in bzl_packages 295 ]), 296 "%%ALL_WHL_REQUIREMENTS%%": _format_repr_list([ 297 macro_tmpl.format(p, "whl") 298 for p in bzl_packages 299 ]), 300 "%%MACRO_TMPL%%": macro_tmpl, 301 "%%NAME%%": rctx.attr.name, 302 "%%REQUIREMENTS_LOCK%%": requirements, 303 }) 304 305def _pip_hub_repository_bzlmod_impl(rctx): 306 bzl_packages = rctx.attr.whl_library_alias_names 307 _create_pip_repository_bzlmod(rctx, bzl_packages, "") 308 309pip_hub_repository_bzlmod_attrs = { 310 "repo_name": attr.string( 311 mandatory = True, 312 doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name.", 313 ), 314 "whl_library_alias_names": attr.string_list( 315 mandatory = True, 316 doc = "The list of whl alias that we use to build aliases and the whl names", 317 ), 318 "_template": attr.label( 319 default = ":pip_hub_repository_requirements_bzlmod.bzl.tmpl", 320 ), 321} 322 323pip_hub_repository_bzlmod = repository_rule( 324 attrs = pip_hub_repository_bzlmod_attrs, 325 doc = """A rule for bzlmod mulitple pip repository creation. PRIVATE USE ONLY.""", 326 implementation = _pip_hub_repository_bzlmod_impl, 327) 328 329def _pip_repository_bzlmod_impl(rctx): 330 requirements_txt = locked_requirements_label(rctx, rctx.attr) 331 content = rctx.read(requirements_txt) 332 parsed_requirements_txt = parse_requirements(content) 333 334 packages = [(normalize_name(name), requirement) for name, requirement in parsed_requirements_txt.requirements] 335 336 bzl_packages = sorted([name for name, _ in packages]) 337 _create_pip_repository_bzlmod(rctx, bzl_packages, str(requirements_txt)) 338 339pip_repository_bzlmod_attrs = { 340 "repo_name": attr.string( 341 mandatory = True, 342 doc = "The apparent name of the repo. This is needed because in bzlmod, the name attribute becomes the canonical name", 343 ), 344 "requirements_darwin": attr.label( 345 allow_single_file = True, 346 doc = "Override the requirements_lock attribute when the host platform is Mac OS", 347 ), 348 "requirements_linux": attr.label( 349 allow_single_file = True, 350 doc = "Override the requirements_lock attribute when the host platform is Linux", 351 ), 352 "requirements_lock": attr.label( 353 allow_single_file = True, 354 doc = """ 355A fully resolved 'requirements.txt' pip requirement file containing the transitive set of your dependencies. If this file is passed instead 356of 'requirements' no resolve will take place and pip_repository will create individual repositories for each of your dependencies so that 357wheels are fetched/built only for the targets specified by 'build/run/test'. 358""", 359 ), 360 "requirements_windows": attr.label( 361 allow_single_file = True, 362 doc = "Override the requirements_lock attribute when the host platform is Windows", 363 ), 364 "_template": attr.label( 365 default = ":pip_repository_requirements_bzlmod.bzl.tmpl", 366 ), 367} 368 369pip_repository_bzlmod = repository_rule( 370 attrs = pip_repository_bzlmod_attrs, 371 doc = """A rule for bzlmod pip_repository creation. Intended for private use only.""", 372 implementation = _pip_repository_bzlmod_impl, 373) 374 375def _pip_repository_impl(rctx): 376 requirements_txt = locked_requirements_label(rctx, rctx.attr) 377 content = rctx.read(requirements_txt) 378 parsed_requirements_txt = parse_requirements(content) 379 380 packages = [(normalize_name(name), requirement) for name, requirement in parsed_requirements_txt.requirements] 381 382 bzl_packages = sorted([name for name, _ in packages]) 383 384 imports = [ 385 'load("@rules_python//python/pip_install:pip_repository.bzl", "whl_library")', 386 ] 387 388 annotations = {} 389 for pkg, annotation in rctx.attr.annotations.items(): 390 filename = "{}.annotation.json".format(normalize_name(pkg)) 391 rctx.file(filename, json.encode_indent(json.decode(annotation))) 392 annotations[pkg] = "@{name}//:{filename}".format(name = rctx.attr.name, filename = filename) 393 394 tokenized_options = [] 395 for opt in parsed_requirements_txt.options: 396 for p in opt.split(" "): 397 tokenized_options.append(p) 398 399 options = tokenized_options + rctx.attr.extra_pip_args 400 401 config = { 402 "download_only": rctx.attr.download_only, 403 "enable_implicit_namespace_pkgs": rctx.attr.enable_implicit_namespace_pkgs, 404 "environment": rctx.attr.environment, 405 "extra_pip_args": options, 406 "isolated": use_isolated(rctx, rctx.attr), 407 "pip_data_exclude": rctx.attr.pip_data_exclude, 408 "python_interpreter": _get_python_interpreter_attr(rctx), 409 "quiet": rctx.attr.quiet, 410 "repo": rctx.attr.name, 411 "repo_prefix": "{}_".format(rctx.attr.name), 412 "timeout": rctx.attr.timeout, 413 } 414 415 if rctx.attr.python_interpreter_target: 416 config["python_interpreter_target"] = str(rctx.attr.python_interpreter_target) 417 418 if rctx.attr.incompatible_generate_aliases: 419 aliases = render_pkg_aliases(repo_name = rctx.attr.name, bzl_packages = bzl_packages) 420 for path, contents in aliases.items(): 421 rctx.file(path, contents) 422 423 rctx.file("BUILD.bazel", _BUILD_FILE_CONTENTS) 424 rctx.template("requirements.bzl", rctx.attr._template, substitutions = { 425 "%%ALL_DATA_REQUIREMENTS%%": _format_repr_list([ 426 "@{}//{}:data".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:data".format(rctx.attr.name, p) 427 for p in bzl_packages 428 ]), 429 "%%ALL_REQUIREMENTS%%": _format_repr_list([ 430 "@{}//{}".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:pkg".format(rctx.attr.name, p) 431 for p in bzl_packages 432 ]), 433 "%%ALL_WHL_REQUIREMENTS%%": _format_repr_list([ 434 "@{}//{}:whl".format(rctx.attr.name, p) if rctx.attr.incompatible_generate_aliases else "@{}_{}//:whl".format(rctx.attr.name, p) 435 for p in bzl_packages 436 ]), 437 "%%ANNOTATIONS%%": _format_dict(_repr_dict(annotations)), 438 "%%CONFIG%%": _format_dict(_repr_dict(config)), 439 "%%EXTRA_PIP_ARGS%%": json.encode(options), 440 "%%IMPORTS%%": "\n".join(sorted(imports)), 441 "%%NAME%%": rctx.attr.name, 442 "%%PACKAGES%%": _format_repr_list( 443 [ 444 ("{}_{}".format(rctx.attr.name, p), r) 445 for p, r in packages 446 ], 447 ), 448 "%%REQUIREMENTS_LOCK%%": str(requirements_txt), 449 }) 450 451 return 452 453common_env = [ 454 "RULES_PYTHON_PIP_ISOLATED", 455] 456 457common_attrs = { 458 "download_only": attr.bool( 459 doc = """ 460Whether to use "pip download" instead of "pip wheel". Disables building wheels from source, but allows use of 461--platform, --python-version, --implementation, and --abi in --extra_pip_args to download wheels for a different 462platform from the host platform. 463 """, 464 ), 465 "enable_implicit_namespace_pkgs": attr.bool( 466 default = False, 467 doc = """ 468If true, disables conversion of native namespace packages into pkg-util style namespace packages. When set all py_binary 469and py_test targets must specify either `legacy_create_init=False` or the global Bazel option 470`--incompatible_default_to_explicit_init_py` to prevent `__init__.py` being automatically generated in every directory. 471 472This option is required to support some packages which cannot handle the conversion to pkg-util style. 473 """, 474 ), 475 "environment": attr.string_dict( 476 doc = """ 477Environment variables to set in the pip subprocess. 478Can be used to set common variables such as `http_proxy`, `https_proxy` and `no_proxy` 479Note that pip is run with "--isolated" on the CLI so `PIP_<VAR>_<NAME>` 480style env vars are ignored, but env vars that control requests and urllib3 481can be passed. 482 """, 483 default = {}, 484 ), 485 "extra_pip_args": attr.string_list( 486 doc = "Extra arguments to pass on to pip. Must not contain spaces.", 487 ), 488 "isolated": attr.bool( 489 doc = """\ 490Whether or not to pass the [--isolated](https://pip.pypa.io/en/stable/cli/pip/#cmdoption-isolated) flag to 491the underlying pip command. Alternatively, the `RULES_PYTHON_PIP_ISOLATED` environment variable can be used 492to control this flag. 493""", 494 default = True, 495 ), 496 "pip_data_exclude": attr.string_list( 497 doc = "Additional data exclusion parameters to add to the pip packages BUILD file.", 498 ), 499 "python_interpreter": attr.string( 500 doc = """\ 501The python interpreter to use. This can either be an absolute path or the name 502of a binary found on the host's `PATH` environment variable. If no value is set 503`python3` is defaulted for Unix systems and `python.exe` for Windows. 504""", 505 # NOTE: This attribute should not have a default. See `_get_python_interpreter_attr` 506 # default = "python3" 507 ), 508 "python_interpreter_target": attr.label( 509 allow_single_file = True, 510 doc = """ 511If you are using a custom python interpreter built by another repository rule, 512use this attribute to specify its BUILD target. This allows pip_repository to invoke 513pip using the same interpreter as your toolchain. If set, takes precedence over 514python_interpreter. An example value: "@python3_x86_64-unknown-linux-gnu//:python". 515""", 516 ), 517 "quiet": attr.bool( 518 default = True, 519 doc = "If True, suppress printing stdout and stderr output to the terminal.", 520 ), 521 "repo_prefix": attr.string( 522 doc = """ 523Prefix for the generated packages will be of the form `@<prefix><sanitized-package-name>//...` 524""", 525 ), 526 # 600 is documented as default here: https://docs.bazel.build/versions/master/skylark/lib/repository_ctx.html#execute 527 "timeout": attr.int( 528 default = 600, 529 doc = "Timeout (in seconds) on the rule's execution duration.", 530 ), 531 "_py_srcs": attr.label_list( 532 doc = "Python sources used in the repository rule", 533 allow_files = True, 534 default = PIP_INSTALL_PY_SRCS, 535 ), 536} 537 538pip_repository_attrs = { 539 "annotations": attr.string_dict( 540 doc = "Optional annotations to apply to packages", 541 ), 542 "incompatible_generate_aliases": attr.bool( 543 default = False, 544 doc = "Allow generating aliases '@pip//<pkg>' -> '@pip_<pkg>//:pkg'.", 545 ), 546 "requirements_darwin": attr.label( 547 allow_single_file = True, 548 doc = "Override the requirements_lock attribute when the host platform is Mac OS", 549 ), 550 "requirements_linux": attr.label( 551 allow_single_file = True, 552 doc = "Override the requirements_lock attribute when the host platform is Linux", 553 ), 554 "requirements_lock": attr.label( 555 allow_single_file = True, 556 doc = """ 557A fully resolved 'requirements.txt' pip requirement file containing the transitive set of your dependencies. If this file is passed instead 558of 'requirements' no resolve will take place and pip_repository will create individual repositories for each of your dependencies so that 559wheels are fetched/built only for the targets specified by 'build/run/test'. 560""", 561 ), 562 "requirements_windows": attr.label( 563 allow_single_file = True, 564 doc = "Override the requirements_lock attribute when the host platform is Windows", 565 ), 566 "_template": attr.label( 567 default = ":pip_repository_requirements.bzl.tmpl", 568 ), 569} 570 571pip_repository_attrs.update(**common_attrs) 572 573pip_repository = repository_rule( 574 attrs = pip_repository_attrs, 575 doc = """A rule for importing `requirements.txt` dependencies into Bazel. 576 577This rule imports a `requirements.txt` file and generates a new 578`requirements.bzl` file. This is used via the `WORKSPACE` pattern: 579 580```python 581pip_repository( 582 name = "foo", 583 requirements = ":requirements.txt", 584) 585``` 586 587You can then reference imported dependencies from your `BUILD` file with: 588 589```python 590load("@foo//:requirements.bzl", "requirement") 591py_library( 592 name = "bar", 593 ... 594 deps = [ 595 "//my/other:dep", 596 requirement("requests"), 597 requirement("numpy"), 598 ], 599) 600``` 601 602Or alternatively: 603```python 604load("@foo//:requirements.bzl", "all_requirements") 605py_binary( 606 name = "baz", 607 ... 608 deps = [ 609 ":foo", 610 ] + all_requirements, 611) 612``` 613""", 614 implementation = _pip_repository_impl, 615 environ = common_env, 616) 617 618def _whl_library_impl(rctx): 619 python_interpreter = _resolve_python_interpreter(rctx) 620 args = [ 621 python_interpreter, 622 "-m", 623 "python.pip_install.tools.wheel_installer.wheel_installer", 624 "--requirement", 625 rctx.attr.requirement, 626 ] 627 628 args = _parse_optional_attrs(rctx, args) 629 630 result = rctx.execute( 631 args, 632 # Manually construct the PYTHONPATH since we cannot use the toolchain here 633 environment = _create_repository_execution_environment(rctx), 634 quiet = rctx.attr.quiet, 635 timeout = rctx.attr.timeout, 636 ) 637 638 if result.return_code: 639 fail("whl_library %s failed: %s (%s) error code: '%s'" % (rctx.attr.name, result.stdout, result.stderr, result.return_code)) 640 641 metadata = json.decode(rctx.read("metadata.json")) 642 rctx.delete("metadata.json") 643 644 entry_points = {} 645 for item in metadata["entry_points"]: 646 name = item["name"] 647 module = item["module"] 648 attribute = item["attribute"] 649 650 # There is an extreme edge-case with entry_points that end with `.py` 651 # See: https://github.com/bazelbuild/bazel/blob/09c621e4cf5b968f4c6cdf905ab142d5961f9ddc/src/test/java/com/google/devtools/build/lib/rules/python/PyBinaryConfiguredTargetTest.java#L174 652 entry_point_without_py = name[:-3] + "_py" if name.endswith(".py") else name 653 entry_point_target_name = ( 654 _WHEEL_ENTRY_POINT_PREFIX + "_" + entry_point_without_py 655 ) 656 entry_point_script_name = entry_point_target_name + ".py" 657 658 rctx.file( 659 entry_point_script_name, 660 _generate_entry_point_contents(module, attribute), 661 ) 662 entry_points[entry_point_without_py] = entry_point_script_name 663 664 build_file_contents = generate_whl_library_build_bazel( 665 repo_prefix = rctx.attr.repo_prefix, 666 dependencies = metadata["deps"], 667 data_exclude = rctx.attr.pip_data_exclude, 668 tags = [ 669 "pypi_name=" + metadata["name"], 670 "pypi_version=" + metadata["version"], 671 ], 672 entry_points = entry_points, 673 annotation = None if not rctx.attr.annotation else struct(**json.decode(rctx.read(rctx.attr.annotation))), 674 ) 675 rctx.file("BUILD.bazel", build_file_contents) 676 677 return 678 679def _generate_entry_point_contents( 680 module, 681 attribute, 682 shebang = "#!/usr/bin/env python3"): 683 """Generate the contents of an entry point script. 684 685 Args: 686 module (str): The name of the module to use. 687 attribute (str): The name of the attribute to call. 688 shebang (str, optional): The shebang to use for the entry point python 689 file. 690 691 Returns: 692 str: A string of python code. 693 """ 694 contents = """\ 695{shebang} 696import sys 697from {module} import {attribute} 698if __name__ == "__main__": 699 sys.exit({attribute}()) 700""".format( 701 shebang = shebang, 702 module = module, 703 attribute = attribute, 704 ) 705 return contents 706 707whl_library_attrs = { 708 "annotation": attr.label( 709 doc = ( 710 "Optional json encoded file containing annotation to apply to the extracted wheel. " + 711 "See `package_annotation`" 712 ), 713 allow_files = True, 714 ), 715 "repo": attr.string( 716 mandatory = True, 717 doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.", 718 ), 719 "requirement": attr.string( 720 mandatory = True, 721 doc = "Python requirement string describing the package to make available", 722 ), 723} 724 725whl_library_attrs.update(**common_attrs) 726 727whl_library = repository_rule( 728 attrs = whl_library_attrs, 729 doc = """ 730Download and extracts a single wheel based into a bazel repo based on the requirement string passed in. 731Instantiated from pip_repository and inherits config options from there.""", 732 implementation = _whl_library_impl, 733 environ = common_env, 734) 735 736def package_annotation( 737 additive_build_content = None, 738 copy_files = {}, 739 copy_executables = {}, 740 data = [], 741 data_exclude_glob = [], 742 srcs_exclude_glob = []): 743 """Annotations to apply to the BUILD file content from package generated from a `pip_repository` rule. 744 745 [cf]: https://github.com/bazelbuild/bazel-skylib/blob/main/docs/copy_file_doc.md 746 747 Args: 748 additive_build_content (str, optional): Raw text to add to the generated `BUILD` file of a package. 749 copy_files (dict, optional): A mapping of `src` and `out` files for [@bazel_skylib//rules:copy_file.bzl][cf] 750 copy_executables (dict, optional): A mapping of `src` and `out` files for 751 [@bazel_skylib//rules:copy_file.bzl][cf]. Targets generated here will also be flagged as 752 executable. 753 data (list, optional): A list of labels to add as `data` dependencies to the generated `py_library` target. 754 data_exclude_glob (list, optional): A list of exclude glob patterns to add as `data` to the generated 755 `py_library` target. 756 srcs_exclude_glob (list, optional): A list of labels to add as `srcs` to the generated `py_library` target. 757 758 Returns: 759 str: A json encoded string of the provided content. 760 """ 761 return json.encode(struct( 762 additive_build_content = additive_build_content, 763 copy_files = copy_files, 764 copy_executables = copy_executables, 765 data = data, 766 data_exclude_glob = data_exclude_glob, 767 srcs_exclude_glob = srcs_exclude_glob, 768 )) 769 770# pip_repository implementation 771 772def _format_list(items): 773 return "[{}]".format(", ".join(items)) 774 775def _format_repr_list(strings): 776 return _format_list( 777 [repr(s) for s in strings], 778 ) 779 780def _repr_dict(items): 781 return {k: repr(v) for k, v in items.items()} 782 783def _format_dict(items): 784 return "{{{}}}".format(", ".join(sorted(['"{}": {}'.format(k, v) for k, v in items.items()]))) 785