diff options
Diffstat (limited to 'python/private/py_wheel.bzl')
-rw-r--r-- | python/private/py_wheel.bzl | 442 |
1 files changed, 442 insertions, 0 deletions
diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl new file mode 100644 index 0000000..d8bceab --- /dev/null +++ b/python/private/py_wheel.bzl @@ -0,0 +1,442 @@ +# Copyright 2023 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"Implementation of py_wheel rule" + +load("//python/private:stamp.bzl", "is_stamping_enabled") +load(":py_package.bzl", "py_package_lib") + +PyWheelInfo = provider( + doc = "Information about a wheel produced by `py_wheel`", + fields = { + "name_file": ( + "File: A file containing the canonical name of the wheel (after " + + "stamping, if enabled)." + ), + "wheel": "File: The wheel file itself.", + }, +) + +_distribution_attrs = { + "abi": attr.string( + default = "none", + doc = "Python ABI tag. 'none' for pure-Python wheels.", + ), + "distribution": attr.string( + mandatory = True, + doc = """\ +Name of the distribution. + +This should match the project name onm PyPI. It's also the name that is used to +refer to the package in other packages' dependencies. + +Workspace status keys are expanded using `{NAME}` format, for example: + - `distribution = "package.{CLASSIFIER}"` + - `distribution = "{DISTRIBUTION}"` + +For the available keys, see https://bazel.build/docs/user-manual#workspace-status +""", + ), + "platform": attr.string( + default = "any", + doc = """\ +Supported platform. Use 'any' for pure-Python wheel. + +If you have included platform-specific data, such as a .pyd or .so +extension module, you will need to specify the platform in standard +pip format. If you support multiple platforms, you can define +platform constraints, then use a select() to specify the appropriate +specifier, eg: + +` +platform = select({ + "//platforms:windows_x86_64": "win_amd64", + "//platforms:macos_x86_64": "macosx_10_7_x86_64", + "//platforms:linux_x86_64": "manylinux2014_x86_64", +}) +` +""", + ), + "python_tag": attr.string( + default = "py3", + doc = "Supported Python version(s), eg `py3`, `cp35.cp36`, etc", + ), + "stamp": attr.int( + doc = """\ +Whether to encode build information into the wheel. Possible values: + +- `stamp = 1`: Always stamp the build information into the wheel, even in \ +[--nostamp](https://docs.bazel.build/versions/main/user-manual.html#flag--stamp) builds. \ +This setting should be avoided, since it potentially kills remote caching for the target and \ +any downstream actions that depend on it. + +- `stamp = 0`: Always replace build information by constant values. This gives good build result caching. + +- `stamp = -1`: Embedding of build information is controlled by the \ +[--[no]stamp](https://docs.bazel.build/versions/main/user-manual.html#flag--stamp) flag. + +Stamped targets are not rebuilt unless their dependencies change. + """, + default = -1, + values = [1, 0, -1], + ), + "version": attr.string( + mandatory = True, + doc = """\ +Version number of the package. + +Note that this attribute supports stamp format strings as well as 'make variables'. +For example: + - `version = "1.2.3-{BUILD_TIMESTAMP}"` + - `version = "{BUILD_EMBED_LABEL}"` + - `version = "$(VERSION)"` + +Note that Bazel's output filename cannot include the stamp information, as outputs must be known +during the analysis phase and the stamp data is available only during the action execution. + +The [`py_wheel`](/docs/packaging.md#py_wheel) macro produces a `.dist`-suffix target which creates a +`dist/` folder containing the wheel with the stamped name, suitable for publishing. + +See [`py_wheel_dist`](/docs/packaging.md#py_wheel_dist) for more info. +""", + ), + "_stamp_flag": attr.label( + doc = "A setting used to determine whether or not the `--stamp` flag is enabled", + default = Label("//python/private:stamp"), + ), +} + +_requirement_attrs = { + "extra_requires": attr.string_list_dict( + doc = "List of optional requirements for this package", + ), + "requires": attr.string_list( + doc = ("List of requirements for this package. See the section on " + + "[Declaring required dependency](https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#declaring-dependencies) " + + "for details and examples of the format of this argument."), + ), +} + +_entrypoint_attrs = { + "console_scripts": attr.string_dict( + doc = """\ +Deprecated console_script entry points, e.g. `{'main': 'examples.wheel.main:main'}`. + +Deprecated: prefer the `entry_points` attribute, which supports `console_scripts` as well as other entry points. +""", + ), + "entry_points": attr.string_list_dict( + doc = """\ +entry_points, e.g. `{'console_scripts': ['main = examples.wheel.main:main']}`. +""", + ), +} + +_other_attrs = { + "author": attr.string( + doc = "A string specifying the author of the package.", + default = "", + ), + "author_email": attr.string( + doc = "A string specifying the email address of the package author.", + default = "", + ), + "classifiers": attr.string_list( + doc = "A list of strings describing the categories for the package. For valid classifiers see https://pypi.org/classifiers", + ), + "description_content_type": attr.string( + doc = ("The type of contents in description_file. " + + "If not provided, the type will be inferred from the extension of description_file. " + + "Also see https://packaging.python.org/en/latest/specifications/core-metadata/#description-content-type"), + ), + "description_file": attr.label( + doc = "A file containing text describing the package.", + allow_single_file = True, + ), + "extra_distinfo_files": attr.label_keyed_string_dict( + doc = "Extra files to add to distinfo directory in the archive.", + allow_files = True, + ), + "homepage": attr.string( + doc = "A string specifying the URL for the package homepage.", + default = "", + ), + "license": attr.string( + doc = "A string specifying the license of the package.", + default = "", + ), + "project_urls": attr.string_dict( + doc = ("A string dict specifying additional browsable URLs for the project and corresponding labels, " + + "where label is the key and url is the value. " + + 'e.g `{{"Bug Tracker": "http://bitbucket.org/tarek/distribute/issues/"}}`'), + ), + "python_requires": attr.string( + doc = ( + "Python versions required by this distribution, e.g. '>=3.5,<3.7'" + ), + default = "", + ), + "strip_path_prefixes": attr.string_list( + default = [], + doc = "path prefixes to strip from files added to the generated package", + ), + "summary": attr.string( + doc = "A one-line summary of what the distribution does", + ), +} + +_PROJECT_URL_LABEL_LENGTH_LIMIT = 32 +_DESCRIPTION_FILE_EXTENSION_TO_TYPE = { + "md": "text/markdown", + "rst": "text/x-rst", +} +_DEFAULT_DESCRIPTION_FILE_TYPE = "text/plain" + +def _escape_filename_segment(segment): + """Escape a segment of the wheel filename. + + See https://www.python.org/dev/peps/pep-0427/#escaping-and-unicode + """ + + # TODO: this is wrong, isalnum replaces non-ascii letters, while we should + # not replace them. + # TODO: replace this with a regexp once starlark supports them. + escaped = "" + for character in segment.elems(): + # isalnum doesn't handle unicode characters properly. + if character.isalnum() or character == ".": + escaped += character + elif not escaped.endswith("_"): + escaped += "_" + return escaped + +def _replace_make_variables(flag, ctx): + """Replace $(VERSION) etc make variables in flag""" + if "$" in flag: + for varname, varsub in ctx.var.items(): + flag = flag.replace("$(%s)" % varname, varsub) + return flag + +def _input_file_to_arg(input_file): + """Converts a File object to string for --input_file argument to wheelmaker""" + return "%s;%s" % (py_package_lib.path_inside_wheel(input_file), input_file.path) + +def _py_wheel_impl(ctx): + abi = _replace_make_variables(ctx.attr.abi, ctx) + python_tag = _replace_make_variables(ctx.attr.python_tag, ctx) + version = _replace_make_variables(ctx.attr.version, ctx) + + outfile = ctx.actions.declare_file("-".join([ + _escape_filename_segment(ctx.attr.distribution), + _escape_filename_segment(version), + _escape_filename_segment(python_tag), + _escape_filename_segment(abi), + _escape_filename_segment(ctx.attr.platform), + ]) + ".whl") + + name_file = ctx.actions.declare_file(ctx.label.name + ".name") + + inputs_to_package = depset( + direct = ctx.files.deps, + ) + + # Inputs to this rule which are not to be packaged. + # Currently this is only the description file (if used). + other_inputs = [] + + # Wrap the inputs into a file to reduce command line length. + packageinputfile = ctx.actions.declare_file(ctx.attr.name + "_target_wrapped_inputs.txt") + content = "" + for input_file in inputs_to_package.to_list(): + content += _input_file_to_arg(input_file) + "\n" + ctx.actions.write(output = packageinputfile, content = content) + other_inputs.append(packageinputfile) + + args = ctx.actions.args() + args.add("--name", ctx.attr.distribution) + args.add("--version", version) + args.add("--python_tag", python_tag) + args.add("--abi", abi) + args.add("--platform", ctx.attr.platform) + args.add("--out", outfile) + args.add("--name_file", name_file) + args.add_all(ctx.attr.strip_path_prefixes, format_each = "--strip_path_prefix=%s") + + # Pass workspace status files if stamping is enabled + if is_stamping_enabled(ctx.attr): + args.add("--volatile_status_file", ctx.version_file) + args.add("--stable_status_file", ctx.info_file) + other_inputs.extend([ctx.version_file, ctx.info_file]) + + args.add("--input_file_list", packageinputfile) + + # Note: Description file and version are not embedded into metadata.txt yet, + # it will be done later by wheelmaker script. + metadata_file = ctx.actions.declare_file(ctx.attr.name + ".metadata.txt") + metadata_contents = ["Metadata-Version: 2.1"] + metadata_contents.append("Name: %s" % ctx.attr.distribution) + + if ctx.attr.author: + metadata_contents.append("Author: %s" % ctx.attr.author) + if ctx.attr.author_email: + metadata_contents.append("Author-email: %s" % ctx.attr.author_email) + if ctx.attr.homepage: + metadata_contents.append("Home-page: %s" % ctx.attr.homepage) + if ctx.attr.license: + metadata_contents.append("License: %s" % ctx.attr.license) + if ctx.attr.description_content_type: + metadata_contents.append("Description-Content-Type: %s" % ctx.attr.description_content_type) + elif ctx.attr.description_file: + # infer the content type from description file extension. + description_file_type = _DESCRIPTION_FILE_EXTENSION_TO_TYPE.get( + ctx.file.description_file.extension, + _DEFAULT_DESCRIPTION_FILE_TYPE, + ) + metadata_contents.append("Description-Content-Type: %s" % description_file_type) + if ctx.attr.summary: + metadata_contents.append("Summary: %s" % ctx.attr.summary) + + for label, url in sorted(ctx.attr.project_urls.items()): + if len(label) > _PROJECT_URL_LABEL_LENGTH_LIMIT: + fail("`label` {} in `project_urls` is too long. It is limited to {} characters.".format(len(label), _PROJECT_URL_LABEL_LENGTH_LIMIT)) + metadata_contents.append("Project-URL: %s, %s" % (label, url)) + + for c in ctx.attr.classifiers: + metadata_contents.append("Classifier: %s" % c) + + if ctx.attr.python_requires: + metadata_contents.append("Requires-Python: %s" % ctx.attr.python_requires) + for requirement in ctx.attr.requires: + metadata_contents.append("Requires-Dist: %s" % requirement) + + for option, option_requirements in sorted(ctx.attr.extra_requires.items()): + metadata_contents.append("Provides-Extra: %s" % option) + for requirement in option_requirements: + metadata_contents.append( + "Requires-Dist: %s; extra == '%s'" % (requirement, option), + ) + ctx.actions.write( + output = metadata_file, + content = "\n".join(metadata_contents) + "\n", + ) + other_inputs.append(metadata_file) + args.add("--metadata_file", metadata_file) + + # Merge console_scripts into entry_points. + entrypoints = dict(ctx.attr.entry_points) # Copy so we can mutate it + if ctx.attr.console_scripts: + # Copy a console_scripts group that may already exist, so we can mutate it. + console_scripts = list(entrypoints.get("console_scripts", [])) + entrypoints["console_scripts"] = console_scripts + for name, ref in ctx.attr.console_scripts.items(): + console_scripts.append("{name} = {ref}".format(name = name, ref = ref)) + + # If any entry_points are provided, construct the file here and add it to the files to be packaged. + # see: https://packaging.python.org/specifications/entry-points/ + if entrypoints: + lines = [] + for group, entries in sorted(entrypoints.items()): + if lines: + # Blank line between groups + lines.append("") + lines.append("[{group}]".format(group = group)) + lines += sorted(entries) + entry_points_file = ctx.actions.declare_file(ctx.attr.name + "_entry_points.txt") + content = "\n".join(lines) + ctx.actions.write(output = entry_points_file, content = content) + other_inputs.append(entry_points_file) + args.add("--entry_points_file", entry_points_file) + + if ctx.attr.description_file: + description_file = ctx.file.description_file + args.add("--description_file", description_file) + other_inputs.append(description_file) + + for target, filename in ctx.attr.extra_distinfo_files.items(): + target_files = target.files.to_list() + if len(target_files) != 1: + fail( + "Multi-file target listed in extra_distinfo_files %s", + filename, + ) + other_inputs.extend(target_files) + args.add( + "--extra_distinfo_file", + filename + ";" + target_files[0].path, + ) + + ctx.actions.run( + inputs = depset(direct = other_inputs, transitive = [inputs_to_package]), + outputs = [outfile, name_file], + arguments = [args], + executable = ctx.executable._wheelmaker, + progress_message = "Building wheel {}".format(ctx.label), + ) + return [ + DefaultInfo( + files = depset([outfile]), + runfiles = ctx.runfiles(files = [outfile]), + ), + PyWheelInfo( + wheel = outfile, + name_file = name_file, + ), + ] + +def _concat_dicts(*dicts): + result = {} + for d in dicts: + result.update(d) + return result + +py_wheel_lib = struct( + implementation = _py_wheel_impl, + attrs = _concat_dicts( + { + "deps": attr.label_list( + doc = """\ +Targets to be included in the distribution. + +The targets to package are usually `py_library` rules or filesets (for packaging data files). + +Note it's usually better to package `py_library` targets and use +`entry_points` attribute to specify `console_scripts` than to package +`py_binary` rules. `py_binary` targets would wrap a executable script that +tries to locate `.runfiles` directory which is not packaged in the wheel. +""", + ), + "_wheelmaker": attr.label( + executable = True, + cfg = "exec", + default = "//tools:wheelmaker", + ), + }, + _distribution_attrs, + _requirement_attrs, + _entrypoint_attrs, + _other_attrs, + ), +) + +py_wheel = rule( + implementation = py_wheel_lib.implementation, + doc = """\ +Internal rule used by the [py_wheel macro](/docs/packaging.md#py_wheel). + +These intentionally have the same name to avoid sharp edges with Bazel macros. +For example, a `bazel query` for a user's `py_wheel` macro expands to `py_wheel` targets, +in the way they expect. +""", + attrs = py_wheel_lib.attrs, +) |