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