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"Implementation of py_wheel rule" 16 17load("//python/private:stamp.bzl", "is_stamping_enabled") 18load(":py_package.bzl", "py_package_lib") 19load(":py_wheel_normalize_pep440.bzl", "normalize_pep440") 20 21PyWheelInfo = provider( 22 doc = "Information about a wheel produced by `py_wheel`", 23 fields = { 24 "name_file": ( 25 "File: A file containing the canonical name of the wheel (after " + 26 "stamping, if enabled)." 27 ), 28 "wheel": "File: The wheel file itself.", 29 }, 30) 31 32_distribution_attrs = { 33 "abi": attr.string( 34 default = "none", 35 doc = "Python ABI tag. 'none' for pure-Python wheels.", 36 ), 37 "distribution": attr.string( 38 mandatory = True, 39 doc = """\ 40Name of the distribution. 41 42This should match the project name on PyPI. It's also the name that is used to 43refer to the package in other packages' dependencies. 44 45Workspace status keys are expanded using `{NAME}` format, for example: 46 - `distribution = "package.{CLASSIFIER}"` 47 - `distribution = "{DISTRIBUTION}"` 48 49For the available keys, see https://bazel.build/docs/user-manual#workspace-status 50""", 51 ), 52 "platform": attr.string( 53 default = "any", 54 doc = """\ 55Supported platform. Use 'any' for pure-Python wheel. 56 57If you have included platform-specific data, such as a .pyd or .so 58extension module, you will need to specify the platform in standard 59pip format. If you support multiple platforms, you can define 60platform constraints, then use a select() to specify the appropriate 61specifier, eg: 62 63` 64platform = select({ 65 "//platforms:windows_x86_64": "win_amd64", 66 "//platforms:macos_x86_64": "macosx_10_7_x86_64", 67 "//platforms:linux_x86_64": "manylinux2014_x86_64", 68}) 69` 70""", 71 ), 72 "python_tag": attr.string( 73 default = "py3", 74 doc = "Supported Python version(s), eg `py3`, `cp35.cp36`, etc", 75 ), 76 "stamp": attr.int( 77 doc = """\ 78Whether to encode build information into the wheel. Possible values: 79 80- `stamp = 1`: Always stamp the build information into the wheel, even in \ 81[--nostamp](https://docs.bazel.build/versions/main/user-manual.html#flag--stamp) builds. \ 82This setting should be avoided, since it potentially kills remote caching for the target and \ 83any downstream actions that depend on it. 84 85- `stamp = 0`: Always replace build information by constant values. This gives good build result caching. 86 87- `stamp = -1`: Embedding of build information is controlled by the \ 88[--[no]stamp](https://docs.bazel.build/versions/main/user-manual.html#flag--stamp) flag. 89 90Stamped targets are not rebuilt unless their dependencies change. 91 """, 92 default = -1, 93 values = [1, 0, -1], 94 ), 95 "version": attr.string( 96 mandatory = True, 97 doc = """\ 98Version number of the package. 99 100Note that this attribute supports stamp format strings as well as 'make variables'. 101For example: 102 - `version = "1.2.3-{BUILD_TIMESTAMP}"` 103 - `version = "{BUILD_EMBED_LABEL}"` 104 - `version = "$(VERSION)"` 105 106Note that Bazel's output filename cannot include the stamp information, as outputs must be known 107during the analysis phase and the stamp data is available only during the action execution. 108 109The [`py_wheel`](#py_wheel) macro produces a `.dist`-suffix target which creates a 110`dist/` folder containing the wheel with the stamped name, suitable for publishing. 111 112See [`py_wheel_dist`](#py_wheel_dist) for more info. 113""", 114 ), 115 "_stamp_flag": attr.label( 116 doc = "A setting used to determine whether or not the `--stamp` flag is enabled", 117 default = Label("//python/private:stamp"), 118 ), 119} 120 121_feature_flags = {} 122 123ALLOWED_DATA_FILE_PREFIX = ("purelib", "platlib", "headers", "scripts", "data") 124_requirement_attrs = { 125 "extra_requires": attr.string_list_dict( 126 doc = ("A mapping of [extras](https://peps.python.org/pep-0508/#extras) options to lists of requirements (similar to `requires`). This attribute " + 127 "is mutually exclusive with `extra_requires_file`."), 128 ), 129 "extra_requires_files": attr.label_keyed_string_dict( 130 doc = ("A mapping of requirements files (similar to `requires_file`) to the name of an [extras](https://peps.python.org/pep-0508/#extras) option " + 131 "This attribute is mutually exclusive with `extra_requires`."), 132 allow_files = True, 133 ), 134 "requires": attr.string_list( 135 doc = ("List of requirements for this package. See the section on " + 136 "[Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) " + 137 "for details and examples of the format of this argument. This " + 138 "attribute is mutually exclusive with `requires_file`."), 139 ), 140 "requires_file": attr.label( 141 doc = ("A file containing a list of requirements for this package. See the section on " + 142 "[Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) " + 143 "for details and examples of the format of this argument. This " + 144 "attribute is mutually exclusive with `requires`."), 145 allow_single_file = True, 146 ), 147} 148 149_entrypoint_attrs = { 150 "console_scripts": attr.string_dict( 151 doc = """\ 152Deprecated console_script entry points, e.g. `{'main': 'examples.wheel.main:main'}`. 153 154Deprecated: prefer the `entry_points` attribute, which supports `console_scripts` as well as other entry points. 155""", 156 ), 157 "entry_points": attr.string_list_dict( 158 doc = """\ 159entry_points, e.g. `{'console_scripts': ['main = examples.wheel.main:main']}`. 160""", 161 ), 162} 163 164_other_attrs = { 165 "author": attr.string( 166 doc = "A string specifying the author of the package.", 167 default = "", 168 ), 169 "author_email": attr.string( 170 doc = "A string specifying the email address of the package author.", 171 default = "", 172 ), 173 "classifiers": attr.string_list( 174 doc = "A list of strings describing the categories for the package. For valid classifiers see https://pypi.org/classifiers", 175 ), 176 "data_files": attr.label_keyed_string_dict( 177 doc = ("Any file that is not normally installed inside site-packages goes into the .data directory, named " + 178 "as the .dist-info directory but with the .data/ extension. Allowed paths: {prefixes}".format(prefixes = ALLOWED_DATA_FILE_PREFIX)), 179 allow_files = True, 180 ), 181 "description_content_type": attr.string( 182 doc = ("The type of contents in description_file. " + 183 "If not provided, the type will be inferred from the extension of description_file. " + 184 "Also see https://packaging.python.org/en/latest/specifications/core-metadata/#description-content-type"), 185 ), 186 "description_file": attr.label( 187 doc = "A file containing text describing the package.", 188 allow_single_file = True, 189 ), 190 "extra_distinfo_files": attr.label_keyed_string_dict( 191 doc = "Extra files to add to distinfo directory in the archive.", 192 allow_files = True, 193 ), 194 "homepage": attr.string( 195 doc = "A string specifying the URL for the package homepage.", 196 default = "", 197 ), 198 "license": attr.string( 199 doc = "A string specifying the license of the package.", 200 default = "", 201 ), 202 "project_urls": attr.string_dict( 203 doc = ("A string dict specifying additional browsable URLs for the project and corresponding labels, " + 204 "where label is the key and url is the value. " + 205 'e.g `{{"Bug Tracker": "http://bitbucket.org/tarek/distribute/issues/"}}`'), 206 ), 207 "python_requires": attr.string( 208 doc = ( 209 "Python versions required by this distribution, e.g. '>=3.5,<3.7'" 210 ), 211 default = "", 212 ), 213 "strip_path_prefixes": attr.string_list( 214 default = [], 215 doc = "path prefixes to strip from files added to the generated package", 216 ), 217 "summary": attr.string( 218 doc = "A one-line summary of what the distribution does", 219 ), 220} 221 222_PROJECT_URL_LABEL_LENGTH_LIMIT = 32 223_DESCRIPTION_FILE_EXTENSION_TO_TYPE = { 224 "md": "text/markdown", 225 "rst": "text/x-rst", 226} 227_DEFAULT_DESCRIPTION_FILE_TYPE = "text/plain" 228 229def _escape_filename_distribution_name(name): 230 """Escape the distribution name component of a filename. 231 232 See https://packaging.python.org/en/latest/specifications/binary-distribution-format/#escaping-and-unicode 233 and https://packaging.python.org/en/latest/specifications/name-normalization/. 234 235 Apart from the valid names according to the above, we also accept 236 '{' and '}', which may be used as placeholders for stamping. 237 """ 238 escaped = "" 239 _inside_stamp_var = False 240 for character in name.elems(): 241 if character == "{": 242 _inside_stamp_var = True 243 escaped += character 244 elif character == "}": 245 _inside_stamp_var = False 246 escaped += character 247 elif character.isalnum(): 248 escaped += character if _inside_stamp_var else character.lower() 249 elif character in ["-", "_", "."]: 250 if escaped == "": 251 fail( 252 "A valid name must start with a letter or number.", 253 "Name '%s' does not." % name, 254 ) 255 elif escaped.endswith("_"): 256 pass 257 else: 258 escaped += "_" 259 else: 260 fail( 261 "A valid name consists only of ASCII letters ", 262 "and numbers, period, underscore and hyphen.", 263 "Name '%s' has bad character '%s'." % (name, character), 264 ) 265 if escaped.endswith("_"): 266 fail( 267 "A valid name must end with a letter or number.", 268 "Name '%s' does not." % name, 269 ) 270 return escaped 271 272def _escape_filename_segment(segment): 273 """Escape a segment of the wheel filename. 274 275 See https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode 276 """ 277 278 # TODO: this is wrong, isalnum replaces non-ascii letters, while we should 279 # not replace them. 280 # TODO: replace this with a regexp once starlark supports them. 281 escaped = "" 282 for character in segment.elems(): 283 # isalnum doesn't handle unicode characters properly. 284 if character.isalnum() or character == ".": 285 escaped += character 286 elif not escaped.endswith("_"): 287 escaped += "_" 288 return escaped 289 290def _replace_make_variables(flag, ctx): 291 """Replace $(VERSION) etc make variables in flag""" 292 if "$" in flag: 293 for varname, varsub in ctx.var.items(): 294 flag = flag.replace("$(%s)" % varname, varsub) 295 return flag 296 297def _input_file_to_arg(input_file): 298 """Converts a File object to string for --input_file argument to wheelmaker""" 299 return "%s;%s" % (py_package_lib.path_inside_wheel(input_file), input_file.path) 300 301def _py_wheel_impl(ctx): 302 abi = _replace_make_variables(ctx.attr.abi, ctx) 303 python_tag = _replace_make_variables(ctx.attr.python_tag, ctx) 304 version = _replace_make_variables(ctx.attr.version, ctx) 305 306 filename_segments = [ 307 _escape_filename_distribution_name(ctx.attr.distribution), 308 normalize_pep440(version), 309 _escape_filename_segment(python_tag), 310 _escape_filename_segment(abi), 311 _escape_filename_segment(ctx.attr.platform), 312 ] 313 314 outfile = ctx.actions.declare_file("-".join(filename_segments) + ".whl") 315 316 name_file = ctx.actions.declare_file(ctx.label.name + ".name") 317 318 inputs_to_package = depset( 319 direct = ctx.files.deps, 320 ) 321 322 # Inputs to this rule which are not to be packaged. 323 # Currently this is only the description file (if used). 324 other_inputs = [] 325 326 # Wrap the inputs into a file to reduce command line length. 327 packageinputfile = ctx.actions.declare_file(ctx.attr.name + "_target_wrapped_inputs.txt") 328 content = "" 329 for input_file in inputs_to_package.to_list(): 330 content += _input_file_to_arg(input_file) + "\n" 331 ctx.actions.write(output = packageinputfile, content = content) 332 other_inputs.append(packageinputfile) 333 334 args = ctx.actions.args() 335 args.add("--name", ctx.attr.distribution) 336 args.add("--version", version) 337 args.add("--python_tag", python_tag) 338 args.add("--abi", abi) 339 args.add("--platform", ctx.attr.platform) 340 args.add("--out", outfile) 341 args.add("--name_file", name_file) 342 args.add_all(ctx.attr.strip_path_prefixes, format_each = "--strip_path_prefix=%s") 343 344 # Pass workspace status files if stamping is enabled 345 if is_stamping_enabled(ctx.attr): 346 args.add("--volatile_status_file", ctx.version_file) 347 args.add("--stable_status_file", ctx.info_file) 348 other_inputs.extend([ctx.version_file, ctx.info_file]) 349 350 args.add("--input_file_list", packageinputfile) 351 352 # Note: Description file and version are not embedded into metadata.txt yet, 353 # it will be done later by wheelmaker script. 354 metadata_file = ctx.actions.declare_file(ctx.attr.name + ".metadata.txt") 355 metadata_contents = ["Metadata-Version: 2.1"] 356 metadata_contents.append("Name: %s" % ctx.attr.distribution) 357 358 if ctx.attr.author: 359 metadata_contents.append("Author: %s" % ctx.attr.author) 360 if ctx.attr.author_email: 361 metadata_contents.append("Author-email: %s" % ctx.attr.author_email) 362 if ctx.attr.homepage: 363 metadata_contents.append("Home-page: %s" % ctx.attr.homepage) 364 if ctx.attr.license: 365 metadata_contents.append("License: %s" % ctx.attr.license) 366 if ctx.attr.description_content_type: 367 metadata_contents.append("Description-Content-Type: %s" % ctx.attr.description_content_type) 368 elif ctx.attr.description_file: 369 # infer the content type from description file extension. 370 description_file_type = _DESCRIPTION_FILE_EXTENSION_TO_TYPE.get( 371 ctx.file.description_file.extension, 372 _DEFAULT_DESCRIPTION_FILE_TYPE, 373 ) 374 metadata_contents.append("Description-Content-Type: %s" % description_file_type) 375 if ctx.attr.summary: 376 metadata_contents.append("Summary: %s" % ctx.attr.summary) 377 378 for label, url in sorted(ctx.attr.project_urls.items()): 379 if len(label) > _PROJECT_URL_LABEL_LENGTH_LIMIT: 380 fail("`label` {} in `project_urls` is too long. It is limited to {} characters.".format(len(label), _PROJECT_URL_LABEL_LENGTH_LIMIT)) 381 metadata_contents.append("Project-URL: %s, %s" % (label, url)) 382 383 for c in ctx.attr.classifiers: 384 metadata_contents.append("Classifier: %s" % c) 385 386 if ctx.attr.python_requires: 387 metadata_contents.append("Requires-Python: %s" % ctx.attr.python_requires) 388 389 if ctx.attr.requires and ctx.attr.requires_file: 390 fail("`requires` and `requires_file` are mutually exclusive. Please update {}".format(ctx.label)) 391 392 for requires in ctx.attr.requires: 393 metadata_contents.append("Requires-Dist: %s" % requires) 394 if ctx.attr.requires_file: 395 # The @ prefixed paths will be resolved by the PyWheel action. 396 # Expanding each line containing a constraint in place of this 397 # directive. 398 metadata_contents.append("Requires-Dist: @%s" % ctx.file.requires_file.path) 399 other_inputs.append(ctx.file.requires_file) 400 401 if ctx.attr.extra_requires and ctx.attr.extra_requires_files: 402 fail("`extra_requires` and `extra_requires_files` are mutually exclusive. Please update {}".format(ctx.label)) 403 for option, option_requirements in sorted(ctx.attr.extra_requires.items()): 404 metadata_contents.append("Provides-Extra: %s" % option) 405 for requirement in option_requirements: 406 metadata_contents.append( 407 "Requires-Dist: %s; extra == '%s'" % (requirement, option), 408 ) 409 extra_requires_files = {} 410 for option_requires_target, option in ctx.attr.extra_requires_files.items(): 411 if option in extra_requires_files: 412 fail("Duplicate `extra_requires_files` option '{}' found on target {}".format(option, ctx.label)) 413 option_requires_files = option_requires_target[DefaultInfo].files.to_list() 414 if len(option_requires_files) != 1: 415 fail("Labels in `extra_requires_files` must result in a single file, but {label} provides {files} from {owner}".format( 416 label = ctx.label, 417 files = option_requires_files, 418 owner = option_requires_target.label, 419 )) 420 extra_requires_files.update({option: option_requires_files[0]}) 421 422 for option, option_requires_file in sorted(extra_requires_files.items()): 423 metadata_contents.append("Provides-Extra: %s" % option) 424 metadata_contents.append( 425 # The @ prefixed paths will be resolved by the PyWheel action. 426 # Expanding each line containing a constraint in place of this 427 # directive and appending the extra option. 428 "Requires-Dist: @%s; extra == '%s'" % (option_requires_file.path, option), 429 ) 430 other_inputs.append(option_requires_file) 431 432 ctx.actions.write( 433 output = metadata_file, 434 content = "\n".join(metadata_contents) + "\n", 435 ) 436 other_inputs.append(metadata_file) 437 args.add("--metadata_file", metadata_file) 438 439 # Merge console_scripts into entry_points. 440 entrypoints = dict(ctx.attr.entry_points) # Copy so we can mutate it 441 if ctx.attr.console_scripts: 442 # Copy a console_scripts group that may already exist, so we can mutate it. 443 console_scripts = list(entrypoints.get("console_scripts", [])) 444 entrypoints["console_scripts"] = console_scripts 445 for name, ref in ctx.attr.console_scripts.items(): 446 console_scripts.append("{name} = {ref}".format(name = name, ref = ref)) 447 448 # If any entry_points are provided, construct the file here and add it to the files to be packaged. 449 # see: https://packaging.python.org/specifications/entry-points/ 450 if entrypoints: 451 lines = [] 452 for group, entries in sorted(entrypoints.items()): 453 if lines: 454 # Blank line between groups 455 lines.append("") 456 lines.append("[{group}]".format(group = group)) 457 lines += sorted(entries) 458 entry_points_file = ctx.actions.declare_file(ctx.attr.name + "_entry_points.txt") 459 content = "\n".join(lines) 460 ctx.actions.write(output = entry_points_file, content = content) 461 other_inputs.append(entry_points_file) 462 args.add("--entry_points_file", entry_points_file) 463 464 if ctx.attr.description_file: 465 description_file = ctx.file.description_file 466 args.add("--description_file", description_file) 467 other_inputs.append(description_file) 468 469 for target, filename in ctx.attr.extra_distinfo_files.items(): 470 target_files = target.files.to_list() 471 if len(target_files) != 1: 472 fail( 473 "Multi-file target listed in extra_distinfo_files %s", 474 filename, 475 ) 476 other_inputs.extend(target_files) 477 args.add( 478 "--extra_distinfo_file", 479 filename + ";" + target_files[0].path, 480 ) 481 482 for target, filename in ctx.attr.data_files.items(): 483 target_files = target.files.to_list() 484 if len(target_files) != 1: 485 fail( 486 "Multi-file target listed in data_files %s", 487 filename, 488 ) 489 490 if filename.partition("/")[0] not in ALLOWED_DATA_FILE_PREFIX: 491 fail( 492 "The target data file must start with one of these prefixes: '%s'. Target filepath: '%s'" % 493 ( 494 ",".join(ALLOWED_DATA_FILE_PREFIX), 495 filename, 496 ), 497 ) 498 other_inputs.extend(target_files) 499 args.add( 500 "--data_files", 501 filename + ";" + target_files[0].path, 502 ) 503 504 ctx.actions.run( 505 mnemonic = "PyWheel", 506 inputs = depset(direct = other_inputs, transitive = [inputs_to_package]), 507 outputs = [outfile, name_file], 508 arguments = [args], 509 executable = ctx.executable._wheelmaker, 510 progress_message = "Building wheel {}".format(ctx.label), 511 ) 512 return [ 513 DefaultInfo( 514 files = depset([outfile]), 515 runfiles = ctx.runfiles(files = [outfile]), 516 ), 517 PyWheelInfo( 518 wheel = outfile, 519 name_file = name_file, 520 ), 521 ] 522 523def _concat_dicts(*dicts): 524 result = {} 525 for d in dicts: 526 result.update(d) 527 return result 528 529py_wheel_lib = struct( 530 implementation = _py_wheel_impl, 531 attrs = _concat_dicts( 532 { 533 "deps": attr.label_list( 534 doc = """\ 535Targets to be included in the distribution. 536 537The targets to package are usually `py_library` rules or filesets (for packaging data files). 538 539Note it's usually better to package `py_library` targets and use 540`entry_points` attribute to specify `console_scripts` than to package 541`py_binary` rules. `py_binary` targets would wrap a executable script that 542tries to locate `.runfiles` directory which is not packaged in the wheel. 543""", 544 ), 545 "_wheelmaker": attr.label( 546 executable = True, 547 cfg = "exec", 548 default = "//tools:wheelmaker", 549 ), 550 }, 551 _distribution_attrs, 552 _feature_flags, 553 _requirement_attrs, 554 _entrypoint_attrs, 555 _other_attrs, 556 ), 557) 558 559py_wheel = rule( 560 implementation = py_wheel_lib.implementation, 561 doc = """\ 562Internal rule used by the [py_wheel macro](#py_wheel). 563 564These intentionally have the same name to avoid sharp edges with Bazel macros. 565For example, a `bazel query` for a user's `py_wheel` macro expands to `py_wheel` targets, 566in the way they expect. 567""", 568 attrs = py_wheel_lib.attrs, 569) 570