From 368ef115c3ea5b0c991a2868f28aee51dbd5f893 Mon Sep 17 00:00:00 2001 From: UebelAndre Date: Sat, 20 Jan 2024 11:23:17 -0800 Subject: [PATCH] feat(py_wheel): Added `requires_file` and `extra_requires_files` attrs --- CHANGELOG.md | 2 ++ examples/wheel/BUILD.bazel | 41 +++++++++++++++++++++++++++++ examples/wheel/wheel_test.py | 20 ++++++++++++++ python/private/py_wheel.bzl | 51 +++++++++++++++++++++++++++++++++--- tools/wheelmaker.py | 17 +++++++++--- 5 files changed, 124 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f0fc9ea4..80450524a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ A brief description of the categories of changes: [0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0 +* feat(py_wheel): Added `requires_file` and `extra_requires_files` attributes. + ### Changed * **BREAKING** The deprecated `incompatible_generate_aliases` feature flags diff --git a/examples/wheel/BUILD.bazel b/examples/wheel/BUILD.bazel index 5c138a227f..d128f97117 100644 --- a/examples/wheel/BUILD.bazel +++ b/examples/wheel/BUILD.bazel @@ -13,6 +13,7 @@ # limitations under the License. load("@bazel_skylib//rules:build_test.bzl", "build_test") +load("@bazel_skylib//rules:write_file.bzl", "write_file") load("//examples/wheel/private:wheel_utils.bzl", "directory_writer", "make_variable_tags") load("//python:defs.bzl", "py_library", "py_test") load("//python:packaging.bzl", "py_package", "py_wheel") @@ -269,6 +270,45 @@ py_wheel( deps = [":example_pkg"], ) +write_file( + name = "requires_file", + out = "requires.txt", + content = """\ +# Requirements file +--index-url https://pypi.com + +tomli>=2.0.0 +""".splitlines(), +) + +write_file( + name = "extra_requires_file", + out = "extra_requires.txt", + content = """\ +# Extras Requirements file +--index-url https://pypi.com + +pyyaml>=6.0.0,!=6.0.1 +""".splitlines(), +) + +# py_wheel can use text files to specify their requirements. This +# can be convenient for users of `compile_pip_requirements` who have +# granular `requirements.in` files per package. This target shows +# how to provide this file. +py_wheel( + name = "requires_files", + distribution = "requires_files", + extra_requires_files = {":extra_requires.txt": "example"}, + python_tag = "py3", + # py_wheel can use text files to specify their requirements. This + # can be convenient for users of `compile_pip_requirements` who have + # granular `requirements.in` files per package. + requires_file = ":requires.txt", + version = "0.0.1", + deps = [":example_pkg"], +) + py_test( name = "wheel_test", srcs = ["wheel_test.py"], @@ -283,6 +323,7 @@ py_test( ":minimal_with_py_package", ":python_abi3_binary_wheel", ":python_requires_in_a_package", + ":requires_files", ":use_rule_with_dir_in_outs", ], deps = [ diff --git a/examples/wheel/wheel_test.py b/examples/wheel/wheel_test.py index 03a94084f0..cab8b5ef49 100644 --- a/examples/wheel/wheel_test.py +++ b/examples/wheel/wheel_test.py @@ -438,6 +438,26 @@ def test_rule_expands_workspace_status_keys_in_wheel_metadata(self): self.assertNotIn("{BUILD_TIMESTAMP}", version) self.assertNotIn("{BUILD_USER}", name) + def test_requires_file_and_extra_requires_files(self): + filename = self._get_path("requires_files-0.0.1-py3-none-any.whl") + + with zipfile.ZipFile(filename) as zf: + self.assertAllEntriesHasReproducibleMetadata(zf) + metadata_file = None + for f in zf.namelist(): + if os.path.basename(f) == "METADATA": + metadata_file = f + self.assertIsNotNone(metadata_file) + + requires = [] + with zf.open(metadata_file) as fp: + for line in fp: + if line.startswith(b"Requires-Dist:"): + requires.append(line.decode("utf-8").strip()) + + self.assertIn("Requires-Dist: tomli>=2.0.0", requires) + self.assertIn("Requires-Dist: pyyaml>=6.0.0,!=6.0.1; extra == 'example'", requires) + if __name__ == "__main__": unittest.main() diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl index bca8615bd6..7b209e9660 100644 --- a/python/private/py_wheel.bzl +++ b/python/private/py_wheel.bzl @@ -122,12 +122,26 @@ _feature_flags = {} _requirement_attrs = { "extra_requires": attr.string_list_dict( - doc = "List of optional requirements for this package", + doc = ("List of optional requirements for this package. This attribute " + + "is mutually exclusive with `extra_requires_file`."), + ), + "extra_requires_files": attr.label_keyed_string_dict( + doc = ("A file containing a list of optional requirements for this package. " + + "This attribute is mutually exclusive with `extra_requires`."), + allow_files = True, ), "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."), + "for details and examples of the format of this argument. This " + + "attribute is mutually exclusive with `requires_file`."), + ), + "requires_file": attr.label( + doc = ("A file containing a 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. This " + + "attribute is mutually exclusive with `requires`."), + allow_single_file = True, ), } @@ -365,15 +379,44 @@ def _py_wheel_impl(ctx): 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) + if ctx.attr.requires and ctx.attr.requires_file: + fail("`requires` and `requires_file` are mutually exclusive. Please update {}".format(ctx.label)) + + for requires in ctx.attr.requires: + metadata_contents.append("Requires-Dist: %s" % requires) + if ctx.attr.requires_file: + metadata_contents.append("Requires-Dist: @%s" % ctx.file.requires_file.path) + other_inputs.append(ctx.file.requires_file) + + if ctx.attr.extra_requires and ctx.attr.extra_requires_files: + fail("`extra_requires` and `extra_requires_files` are mutually exclusive. Please update {}".format(ctx.label)) 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), ) + extra_requires_files = {} + for option_requires_target, option in ctx.attr.extra_requires_files.items(): + if option in extra_requires_files: + fail("Duplicate `extra_requires_files` option '{}' found on target {}".format(option, ctx.label)) + option_requires_files = option_requires_target[DefaultInfo].files.to_list() + if len(option_requires_files) != 1: + fail("Unexpected number of files provided by {} via `extra_requires_files` on {}".format( + option_requires_target, + option_requires_files, + ctx.label, + )) + extra_requires_files.update({option: option_requires_files[0]}) + + for option, option_requires_file in sorted(extra_requires_files.items()): + metadata_contents.append("Provides-Extra: %s" % option) + metadata_contents.append( + "Requires-Dist: @%s; extra == '%s'" % (option_requires_file.path, option), + ) + other_inputs.append(option_requires_file) + ctx.actions.write( output = metadata_file, content = "\n".join(metadata_contents) + "\n", diff --git a/tools/wheelmaker.py b/tools/wheelmaker.py index e2d0121b93..f486bb594c 100644 --- a/tools/wheelmaker.py +++ b/tools/wheelmaker.py @@ -510,9 +510,20 @@ def main() -> None: ) as description_file: description = description_file.read() - metadata = None - with open(arguments.metadata_file, "rt", encoding="utf-8") as metadata_file: - metadata = metadata_file.read() + metadata = arguments.metadata_file.read_text(encoding="utf-8") + for meta_line in metadata.splitlines(): + if not meta_line.startswith("Requires-Dist: @"): + continue + file, _, extra = meta_line[len("Requires-Dist: @") :].partition(";") + + reqs = [] + for reqs_line in Path(file).read_text(encoding="utf-8").splitlines(): + reqs_text = reqs_line.strip() + if not reqs_text or reqs_text.startswith(("#", "-")): + continue + + reqs.append(f"Requires-Dist: {reqs_text};{extra}".strip("; ")) + metadata = metadata.replace(meta_line, "\n".join(reqs)) maker.add_metadata( metadata=metadata,