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/private:auth.bzl", "AUTH_ATTRS", "get_auth") 18load("//python/private:envsubst.bzl", "envsubst") 19load("//python/private:is_standalone_interpreter.bzl", "is_standalone_interpreter") 20load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "repo_utils") 21load(":attrs.bzl", "ATTRS", "use_isolated") 22load(":deps.bzl", "all_repo_names") 23load(":generate_whl_library_build_bazel.bzl", "generate_whl_library_build_bazel") 24load(":parse_whl_name.bzl", "parse_whl_name") 25load(":patch_whl.bzl", "patch_whl") 26load(":pypi_repo_utils.bzl", "pypi_repo_utils") 27load(":whl_target_platforms.bzl", "whl_target_platforms") 28 29_CPPFLAGS = "CPPFLAGS" 30_COMMAND_LINE_TOOLS_PATH_SLUG = "commandlinetools" 31_WHEEL_ENTRY_POINT_PREFIX = "rules_python_wheel_entry_point" 32 33def _get_xcode_location_cflags(rctx): 34 """Query the xcode sdk location to update cflags 35 36 Figure out if this interpreter target comes from rules_python, and patch the xcode sdk location if so. 37 Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg 38 otherwise. See https://github.com/indygreg/python-build-standalone/issues/103 39 """ 40 41 # Only run on MacOS hosts 42 if not rctx.os.name.lower().startswith("mac os"): 43 return [] 44 45 xcode_sdk_location = repo_utils.execute_unchecked( 46 rctx, 47 op = "GetXcodeLocation", 48 arguments = [repo_utils.which_checked(rctx, "xcode-select"), "--print-path"], 49 ) 50 if xcode_sdk_location.return_code != 0: 51 return [] 52 53 xcode_root = xcode_sdk_location.stdout.strip() 54 if _COMMAND_LINE_TOOLS_PATH_SLUG not in xcode_root.lower(): 55 # This is a full xcode installation somewhere like /Applications/Xcode13.0.app/Contents/Developer 56 # so we need to change the path to to the macos specific tools which are in a different relative 57 # path than xcode installed command line tools. 58 xcode_root = "{}/Platforms/MacOSX.platform/Developer".format(xcode_root) 59 return [ 60 "-isysroot {}/SDKs/MacOSX.sdk".format(xcode_root), 61 ] 62 63def _get_toolchain_unix_cflags(rctx, python_interpreter, logger = None): 64 """Gather cflags from a standalone toolchain for unix systems. 65 66 Pip won't be able to compile c extensions from sdists with the pre built python distributions from indygreg 67 otherwise. See https://github.com/indygreg/python-build-standalone/issues/103 68 """ 69 70 # Only run on Unix systems 71 if not rctx.os.name.lower().startswith(("mac os", "linux")): 72 return [] 73 74 # Only update the location when using a standalone toolchain. 75 if not is_standalone_interpreter(rctx, python_interpreter, logger = logger): 76 return [] 77 78 stdout = repo_utils.execute_checked_stdout( 79 rctx, 80 op = "GetPythonVersionForUnixCflags", 81 arguments = [ 82 python_interpreter, 83 "-c", 84 "import sys; print(f'{sys.version_info[0]}.{sys.version_info[1]}', end='')", 85 ], 86 ) 87 _python_version = stdout 88 include_path = "{}/include/python{}".format( 89 python_interpreter.dirname, 90 _python_version, 91 ) 92 93 return ["-isystem {}".format(include_path)] 94 95def _parse_optional_attrs(rctx, args, extra_pip_args = None): 96 """Helper function to parse common attributes of pip_repository and whl_library repository rules. 97 98 This function also serializes the structured arguments as JSON 99 so they can be passed on the command line to subprocesses. 100 101 Args: 102 rctx: Handle to the rule repository context. 103 args: A list of parsed args for the rule. 104 extra_pip_args: The pip args to pass. 105 Returns: Augmented args list. 106 """ 107 108 if use_isolated(rctx, rctx.attr): 109 args.append("--isolated") 110 111 # Bazel version 7.1.0 and later (and rolling releases from version 8.0.0-pre.20240128.3) 112 # support rctx.getenv(name, default): When building incrementally, any change to the value of 113 # the variable named by name will cause this repository to be re-fetched. 114 if "getenv" in dir(rctx): 115 getenv = rctx.getenv 116 else: 117 getenv = rctx.os.environ.get 118 119 # Check for None so we use empty default types from our attrs. 120 # Some args want to be list, and some want to be dict. 121 if extra_pip_args != None: 122 args += [ 123 "--extra_pip_args", 124 json.encode(struct(arg = [ 125 envsubst(pip_arg, rctx.attr.envsubst, getenv) 126 for pip_arg in extra_pip_args 127 ])), 128 ] 129 130 if rctx.attr.download_only: 131 args.append("--download_only") 132 133 if rctx.attr.pip_data_exclude != None: 134 args += [ 135 "--pip_data_exclude", 136 json.encode(struct(arg = rctx.attr.pip_data_exclude)), 137 ] 138 139 if rctx.attr.enable_implicit_namespace_pkgs: 140 args.append("--enable_implicit_namespace_pkgs") 141 142 if rctx.attr.environment != None: 143 args += [ 144 "--environment", 145 json.encode(struct(arg = rctx.attr.environment)), 146 ] 147 148 return args 149 150def _create_repository_execution_environment(rctx, python_interpreter, logger = None): 151 """Create a environment dictionary for processes we spawn with rctx.execute. 152 153 Args: 154 rctx (repository_ctx): The repository context. 155 python_interpreter (path): The resolved python interpreter. 156 logger: Optional logger to use for operations. 157 Returns: 158 Dictionary of environment variable suitable to pass to rctx.execute. 159 """ 160 161 # Gather any available CPPFLAGS values 162 cppflags = [] 163 cppflags.extend(_get_xcode_location_cflags(rctx)) 164 cppflags.extend(_get_toolchain_unix_cflags(rctx, python_interpreter, logger = logger)) 165 166 env = { 167 "PYTHONPATH": pypi_repo_utils.construct_pythonpath( 168 rctx, 169 entries = rctx.attr._python_path_entries, 170 ), 171 _CPPFLAGS: " ".join(cppflags), 172 } 173 174 return env 175 176def _whl_library_impl(rctx): 177 logger = repo_utils.logger(rctx) 178 python_interpreter = pypi_repo_utils.resolve_python_interpreter( 179 rctx, 180 python_interpreter = rctx.attr.python_interpreter, 181 python_interpreter_target = rctx.attr.python_interpreter_target, 182 ) 183 args = [ 184 python_interpreter, 185 "-m", 186 "python.private.pypi.whl_installer.wheel_installer", 187 "--requirement", 188 rctx.attr.requirement, 189 ] 190 extra_pip_args = [] 191 extra_pip_args.extend(rctx.attr.extra_pip_args) 192 193 # Manually construct the PYTHONPATH since we cannot use the toolchain here 194 environment = _create_repository_execution_environment(rctx, python_interpreter, logger = logger) 195 196 whl_path = None 197 if rctx.attr.whl_file: 198 whl_path = rctx.path(rctx.attr.whl_file) 199 200 # Simulate the behaviour where the whl is present in the current directory. 201 rctx.symlink(whl_path, whl_path.basename) 202 whl_path = rctx.path(whl_path.basename) 203 elif rctx.attr.urls: 204 filename = rctx.attr.filename 205 urls = rctx.attr.urls 206 if not filename: 207 _, _, filename = urls[0].rpartition("/") 208 209 if not (filename.endswith(".whl") or filename.endswith("tar.gz") or filename.endswith(".zip")): 210 if rctx.attr.filename: 211 msg = "got '{}'".format(filename) 212 else: 213 msg = "detected '{}' from url:\n{}".format(filename, urls[0]) 214 fail("Only '.whl', '.tar.gz' or '.zip' files are supported, {}".format(msg)) 215 216 result = rctx.download( 217 url = urls, 218 output = filename, 219 sha256 = rctx.attr.sha256, 220 auth = get_auth(rctx, urls), 221 ) 222 223 if not result.success: 224 fail("could not download the '{}' from {}:\n{}".format(filename, urls, result)) 225 226 if filename.endswith(".whl"): 227 whl_path = rctx.path(rctx.attr.filename) 228 else: 229 # It is an sdist and we need to tell PyPI to use a file in this directory 230 # and, allow getting build dependencies from PYTHONPATH, which we 231 # setup in this repository rule, but still download any necessary 232 # build deps from PyPI (e.g. `flit_core`) if they are missing. 233 extra_pip_args.extend(["--no-build-isolation", "--find-links", "."]) 234 235 args = _parse_optional_attrs(rctx, args, extra_pip_args) 236 237 if not whl_path: 238 if rctx.attr.urls: 239 op_tmpl = "whl_library.BuildWheelFromSource({name}, {requirement})" 240 elif rctx.attr.download_only: 241 op_tmpl = "whl_library.DownloadWheel({name}, {requirement})" 242 else: 243 op_tmpl = "whl_library.ResolveRequirement({name}, {requirement})" 244 245 repo_utils.execute_checked( 246 rctx, 247 op = op_tmpl.format(name = rctx.attr.name, requirement = rctx.attr.requirement), 248 arguments = args, 249 environment = environment, 250 quiet = rctx.attr.quiet, 251 timeout = rctx.attr.timeout, 252 logger = logger, 253 ) 254 255 whl_path = rctx.path(json.decode(rctx.read("whl_file.json"))["whl_file"]) 256 if not rctx.delete("whl_file.json"): 257 fail("failed to delete the whl_file.json file") 258 259 if rctx.attr.whl_patches: 260 patches = {} 261 for patch_file, json_args in rctx.attr.whl_patches.items(): 262 patch_dst = struct(**json.decode(json_args)) 263 if whl_path.basename in patch_dst.whls: 264 patches[patch_file] = patch_dst.patch_strip 265 266 whl_path = patch_whl( 267 rctx, 268 op = "whl_library.PatchWhl({}, {})".format(rctx.attr.name, rctx.attr.requirement), 269 python_interpreter = python_interpreter, 270 whl_path = whl_path, 271 patches = patches, 272 quiet = rctx.attr.quiet, 273 timeout = rctx.attr.timeout, 274 ) 275 276 target_platforms = rctx.attr.experimental_target_platforms 277 if target_platforms: 278 parsed_whl = parse_whl_name(whl_path.basename) 279 if parsed_whl.platform_tag != "any": 280 # NOTE @aignas 2023-12-04: if the wheel is a platform specific 281 # wheel, we only include deps for that target platform 282 target_platforms = [ 283 p.target_platform 284 for p in whl_target_platforms( 285 platform_tag = parsed_whl.platform_tag, 286 abi_tag = parsed_whl.abi_tag, 287 ) 288 ] 289 290 repo_utils.execute_checked( 291 rctx, 292 op = "whl_library.ExtractWheel({}, {})".format(rctx.attr.name, whl_path), 293 arguments = args + [ 294 "--whl-file", 295 whl_path, 296 ] + ["--platform={}".format(p) for p in target_platforms], 297 environment = environment, 298 quiet = rctx.attr.quiet, 299 timeout = rctx.attr.timeout, 300 logger = logger, 301 ) 302 303 metadata = json.decode(rctx.read("metadata.json")) 304 rctx.delete("metadata.json") 305 306 # NOTE @aignas 2024-06-22: this has to live on until we stop supporting 307 # passing `twine` as a `:pkg` library via the `WORKSPACE` builds. 308 # 309 # See ../../packaging.bzl line 190 310 entry_points = {} 311 for item in metadata["entry_points"]: 312 name = item["name"] 313 module = item["module"] 314 attribute = item["attribute"] 315 316 # There is an extreme edge-case with entry_points that end with `.py` 317 # See: https://github.com/bazelbuild/bazel/blob/09c621e4cf5b968f4c6cdf905ab142d5961f9ddc/src/test/java/com/google/devtools/build/lib/rules/python/PyBinaryConfiguredTargetTest.java#L174 318 entry_point_without_py = name[:-3] + "_py" if name.endswith(".py") else name 319 entry_point_target_name = ( 320 _WHEEL_ENTRY_POINT_PREFIX + "_" + entry_point_without_py 321 ) 322 entry_point_script_name = entry_point_target_name + ".py" 323 324 rctx.file( 325 entry_point_script_name, 326 _generate_entry_point_contents(module, attribute), 327 ) 328 entry_points[entry_point_without_py] = entry_point_script_name 329 330 build_file_contents = generate_whl_library_build_bazel( 331 dep_template = rctx.attr.dep_template or "@{}{{name}}//:{{target}}".format(rctx.attr.repo_prefix), 332 whl_name = whl_path.basename, 333 dependencies = metadata["deps"], 334 dependencies_by_platform = metadata["deps_by_platform"], 335 group_name = rctx.attr.group_name, 336 group_deps = rctx.attr.group_deps, 337 data_exclude = rctx.attr.pip_data_exclude, 338 tags = [ 339 "pypi_name=" + metadata["name"], 340 "pypi_version=" + metadata["version"], 341 ], 342 entry_points = entry_points, 343 annotation = None if not rctx.attr.annotation else struct(**json.decode(rctx.read(rctx.attr.annotation))), 344 ) 345 rctx.file("BUILD.bazel", build_file_contents) 346 347 return 348 349def _generate_entry_point_contents( 350 module, 351 attribute, 352 shebang = "#!/usr/bin/env python3"): 353 """Generate the contents of an entry point script. 354 355 Args: 356 module (str): The name of the module to use. 357 attribute (str): The name of the attribute to call. 358 shebang (str, optional): The shebang to use for the entry point python 359 file. 360 361 Returns: 362 str: A string of python code. 363 """ 364 contents = """\ 365{shebang} 366import sys 367from {module} import {attribute} 368if __name__ == "__main__": 369 sys.exit({attribute}()) 370""".format( 371 shebang = shebang, 372 module = module, 373 attribute = attribute, 374 ) 375 return contents 376 377# NOTE @aignas 2024-03-21: The usage of dict({}, **common) ensures that all args to `dict` are unique 378whl_library_attrs = dict({ 379 "annotation": attr.label( 380 doc = ( 381 "Optional json encoded file containing annotation to apply to the extracted wheel. " + 382 "See `package_annotation`" 383 ), 384 allow_files = True, 385 ), 386 "dep_template": attr.string( 387 doc = """ 388The dep template to use for referencing the dependencies. It should have `{name}` 389and `{target}` tokens that will be replaced with the normalized distribution name 390and the target that we need respectively. 391""", 392 ), 393 "filename": attr.string( 394 doc = "Download the whl file to this filename. Only used when the `urls` is passed. If not specified, will be auto-detected from the `urls`.", 395 ), 396 "group_deps": attr.string_list( 397 doc = "List of dependencies to skip in order to break the cycles within a dependency group.", 398 default = [], 399 ), 400 "group_name": attr.string( 401 doc = "Name of the group, if any.", 402 ), 403 "repo": attr.string( 404 mandatory = True, 405 doc = "Pointer to parent repo name. Used to make these rules rerun if the parent repo changes.", 406 ), 407 "repo_prefix": attr.string( 408 doc = """ 409Prefix for the generated packages will be of the form `@<prefix><sanitized-package-name>//...` 410 411DEPRECATED. Only left for people who vendor requirements.bzl. 412""", 413 ), 414 "requirement": attr.string( 415 mandatory = True, 416 doc = "Python requirement string describing the package to make available, if 'urls' or 'whl_file' is given, then this only needs to include foo[any_extras] as a bare minimum.", 417 ), 418 "sha256": attr.string( 419 doc = "The sha256 of the downloaded whl. Only used when the `urls` is passed.", 420 ), 421 "urls": attr.string_list( 422 doc = """\ 423The list of urls of the whl to be downloaded using bazel downloader. Using this 424attr makes `extra_pip_args` and `download_only` ignored.""", 425 ), 426 "whl_file": attr.label( 427 doc = "The whl file that should be used instead of downloading or building the whl.", 428 ), 429 "whl_patches": attr.label_keyed_string_dict( 430 doc = """a label-keyed-string dict that has 431 json.encode(struct([whl_file], patch_strip]) as values. This 432 is to maintain flexibility and correct bzlmod extension interface 433 until we have a better way to define whl_library and move whl 434 patching to a separate place. INTERNAL USE ONLY.""", 435 ), 436 "_python_path_entries": attr.label_list( 437 # Get the root directory of these rules and keep them as a default attribute 438 # in order to avoid unnecessary repository fetching restarts. 439 # 440 # This is very similar to what was done in https://github.com/bazelbuild/rules_go/pull/3478 441 default = [ 442 Label("//:BUILD.bazel"), 443 ] + [ 444 # Includes all the external dependencies from repositories.bzl 445 Label("@" + repo + "//:BUILD.bazel") 446 for repo in all_repo_names 447 ], 448 ), 449 "_rule_name": attr.string(default = "whl_library"), 450}, **ATTRS) 451whl_library_attrs.update(AUTH_ATTRS) 452 453whl_library = repository_rule( 454 attrs = whl_library_attrs, 455 doc = """ 456Download and extracts a single wheel based into a bazel repo based on the requirement string passed in. 457Instantiated from pip_repository and inherits config options from there.""", 458 implementation = _whl_library_impl, 459 environ = [ 460 "RULES_PYTHON_PIP_ISOLATED", 461 REPO_DEBUG_ENV_VAR, 462 ], 463) 464