From 1e6d7ced4df44e2d8b4e7511f3a42415d4caae1d Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 26 Oct 2026 20:22:51 +0000 Subject: [PATCH 1/2] feat(rules): add py_dist_info rule for importlib.metadata Libraries built directly from source with py_library (rather than installed from a wheel) do not have a .dist-info directory in runfiles, so runtime queries via importlib.metadata.version() or importlib.metadata.entry_points() fail unless the library is patched. This adds py_dist_info in //python:py_dist_info.bzl, which writes METADATA and entry_points.txt files and stages them via root_symlinks under /[target_path/].dist-info in runfiles so importlib.metadata can discover them on sys.path. --- docs/BUILD.bazel | 2 + news/py-dist-info.added.md | 4 + python/BUILD.bazel | 9 ++ python/features.bzl | 5 + python/private/BUILD.bazel | 5 + python/private/py_dist_info.bzl | 127 ++++++++++++++++++ python/py_dist_info.bzl | 16 +++ tests/BUILD.bazel | 1 + tests/py_dist_info/BUILD.bazel | 34 +++++ .../py_dist_info/py_dist_info_runtime_test.py | 24 ++++ tests/py_dist_info/py_dist_info_tests.bzl | 97 +++++++++++++ tests/py_dist_info/sample_pkg.py | 2 + 12 files changed, 326 insertions(+) create mode 100644 news/py-dist-info.added.md create mode 100644 python/private/py_dist_info.bzl create mode 100644 python/py_dist_info.bzl create mode 100644 tests/py_dist_info/BUILD.bazel create mode 100644 tests/py_dist_info/py_dist_info_runtime_test.py create mode 100644 tests/py_dist_info/py_dist_info_tests.bzl create mode 100644 tests/py_dist_info/sample_pkg.py diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel index b0217c121c..7473c7bd51 100644 --- a/docs/BUILD.bazel +++ b/docs/BUILD.bazel @@ -112,6 +112,7 @@ sphinx_stardocs( "//python:proto", "//python:py_binary", "//python:py_cc_link_params_info", + "//python:py_dist_info", "//python:py_exec_tools_info", "//python:py_exec_tools_toolchain", "//python:py_executable_info", @@ -139,6 +140,7 @@ sphinx_stardocs( "//python/private:builders_util", "//python/private:py_binary_rule", "//python/private:py_cc_toolchain_rule", + "//python/private:py_dist_info", "//python/private:py_info", "//python/private:py_library_rule", "//python/private:py_runtime_rule", diff --git a/news/py-dist-info.added.md b/news/py-dist-info.added.md new file mode 100644 index 0000000000..4847e13f17 --- /dev/null +++ b/news/py-dist-info.added.md @@ -0,0 +1,4 @@ +(rules) Added {obj}`py_dist_info` in `@rules_python//python:py_dist_info.bzl` +to generate `.dist-info` metadata (`METADATA` and `entry_points.txt`) in +runfiles so `importlib.metadata` can discover package versions and entry points +at runtime. diff --git a/python/BUILD.bazel b/python/BUILD.bazel index e0e32c5d72..275efe0817 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -52,6 +52,7 @@ filegroup( "defs.bzl", "packaging.bzl", "pip.bzl", + "py_dist_info.bzl", "pytest_test.bzl", "repositories.bzl", "versions.bzl", @@ -65,6 +66,7 @@ filegroup( exports_files([ "defs.bzl", + "py_dist_info.bzl", "pytest_test.bzl", "python.bzl", # Deprecated, please use defs.bzl ]) @@ -276,6 +278,13 @@ alias( deprecation = "Use //python:py_cc_link_params_info instead", ) +# keep +bzl_library( + name = "py_dist_info", + srcs = ["py_dist_info.bzl"], + deps = ["//python/private:py_dist_info"], +) + bzl_library( name = "py_exec_tools_info", srcs = ["py_exec_tools_info.bzl"], diff --git a/python/features.bzl b/python/features.bzl index 99050a7ca5..a695a13b8a 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -121,6 +121,7 @@ _TARGETS = { "//python:proto": True, "//python:py_binary": True, "//python:py_cc_link_params_info": True, + "//python:py_dist_info": True, "//python:py_exec_tools_info": True, "//python:py_exec_tools_toolchain": True, "//python:py_executable_info": True, @@ -140,6 +141,10 @@ _LOADABLE_SYMBOLS = { # keep sorted "py_extension", ], + "//python:py_dist_info.bzl": [ + # keep sorted + "py_dist_info", + ], "//python:py_info.bzl": [ # keep sorted "PyInfo", diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index 075b77b05d..fa87d9ba9f 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -995,6 +995,11 @@ bzl_library( srcs = ["py_console_script_gen.bzl"], ) +bzl_library( + name = "py_dist_info", + srcs = ["py_dist_info.bzl"], +) + bzl_library( name = "py_exec_tools_info", srcs = ["py_exec_tools_info.bzl"], diff --git a/python/private/py_dist_info.bzl b/python/private/py_dist_info.bzl new file mode 100644 index 0000000000..dbb9606950 --- /dev/null +++ b/python/private/py_dist_info.bzl @@ -0,0 +1,127 @@ +"""Implementation of the py_dist_info rule.""" + +_METADATA_TEMPLATE = """\ +Metadata-Version: 2.1 +Name: {name} +Version: {version} +""" + +def _py_dist_info_rule_impl(ctx): + package_name = ctx.attr.package_name + if not package_name: + fail("package_name attribute is required") + version = ctx.attr.version + if not version: + fail("version attribute is required") + + # Prepare entry_points.txt content + entry_points_lines = [] + for group, points in ctx.attr.entry_points.items(): + entry_points_lines.append("[%s]" % group) + entry_points_lines.extend(points) + entry_points_lines.append("") + entry_points_content = "\n".join(entry_points_lines) + + # Use unique names for staging files to avoid clashes + entry_points_staging_file = ctx.actions.declare_file( + ctx.label.name + "_entry_points.txt", + ) + ctx.actions.write(entry_points_staging_file, entry_points_content) + + # Prepare METADATA content + metadata_content = _METADATA_TEMPLATE.format( + name = package_name, + version = version, + ) + metadata_staging_file = ctx.actions.declare_file( + ctx.label.name + "_METADATA", + ) + ctx.actions.write(metadata_staging_file, metadata_content) + + # Create runfiles with the .dist-info directory structure + target_path = ctx.attr.target_path + path_components = [ctx.label.repo_name or ctx.workspace_name] + if target_path: + path_components.append(target_path) + path_components.append(package_name + ".dist-info") + dist_info_dirname = "/".join(path_components) + + metadata_symlink_path = dist_info_dirname + "/METADATA" + entry_points_symlink_path = dist_info_dirname + "/entry_points.txt" + + runfiles = ctx.runfiles( + files = [entry_points_staging_file, metadata_staging_file], + root_symlinks = { + metadata_symlink_path: metadata_staging_file, + entry_points_symlink_path: entry_points_staging_file, + }, + ) + + return [ + DefaultInfo( + # We don't set `files` (default outputs) because the files only + # make sense to show up where the root symlinks put them. Other + # locations (ones not on sys.path) wouldn't work. + runfiles = runfiles, + ), + ] + +py_dist_info_rule = rule( + implementation = _py_dist_info_rule_impl, + doc = """ +Generates `.dist-info` package metadata in runfiles for `importlib.metadata`. + +This rule generates a `.dist-info` directory containing `METADATA` +and `entry_points.txt` files in the runfiles tree so Python's +`importlib.metadata` can discover package versions and entry points at runtime. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + attrs = { + "entry_points": attr.string_list_dict( + doc = """ +A dictionary mapping entry point group names to lists of entry point +specifications (e.g. `{"console_scripts": ["my_cli = my_pkg.cli:main"]}`). +""", + ), + "package_name": attr.string( + mandatory = True, + doc = """ +The distribution package name written to `METADATA` and used for the +`.dist-info` directory name. +""", + ), + "target_path": attr.string( + default = "", + doc = """ +The target path for the `.dist-info` directory, relative to the repository root +in runfiles. If set, this directory must be on `sys.path` (for example via the +`imports` attribute of a `py_library`) for `importlib.metadata` to discover it. +""", + ), + "version": attr.string( + mandatory = True, + doc = """ +The distribution package version written to `METADATA`. +""", + ), + }, +) + +def py_dist_info(**kwargs): + """Declares Python package metadata for `importlib.metadata`. + + This macro wraps {rule}`py_dist_info_rule` and generates the necessary files + for Python's `importlib.metadata` to discover package information, including + versions and entry points, at runtime. Include the `py_dist_info` target in + the `data` attribute of the corresponding {obj}`py_library`, + {obj}`py_binary`, or {obj}`py_test`. + + :::{versionadded} VERSION_NEXT_FEATURE + ::: + + Args: + **kwargs: Rule attributes forwarded to {rule}`py_dist_info_rule`. + """ + py_dist_info_rule(**kwargs) diff --git a/python/py_dist_info.bzl b/python/py_dist_info.bzl new file mode 100644 index 0000000000..e6c10a5071 --- /dev/null +++ b/python/py_dist_info.bzl @@ -0,0 +1,16 @@ +"""Public entry point for `py_dist_info`. + +Use {obj}`py_dist_info` to generate `.dist-info` metadata (`METADATA` and +`entry_points.txt`) in runfiles so that Python's `importlib.metadata` can +discover package versions and entry points at runtime. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""" + +load( + "//python/private:py_dist_info.bzl", + _py_dist_info = "py_dist_info", +) + +py_dist_info = _py_dist_info diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel index 50a3b005a6..2ee048bc96 100644 --- a/tests/BUILD.bazel +++ b/tests/BUILD.bazel @@ -13,6 +13,7 @@ build_test( "//python:proto_bzl", "//python:py_binary_bzl", "//python:py_cc_link_params_info_bzl", + "//python:py_dist_info", "//python:py_import_bzl", "//python:py_info_bzl", "//python:py_library_bzl", diff --git a/tests/py_dist_info/BUILD.bazel b/tests/py_dist_info/BUILD.bazel new file mode 100644 index 0000000000..6fdbbae612 --- /dev/null +++ b/tests/py_dist_info/BUILD.bazel @@ -0,0 +1,34 @@ +load("//python:py_dist_info.bzl", "py_dist_info") +load("//python:py_library.bzl", "py_library") +load("//python:py_test.bzl", "py_test") +load(":py_dist_info_tests.bzl", "py_dist_info_test_suite") + +package(default_visibility = ["//visibility:private"]) + +licenses(["notice"]) + +py_dist_info_test_suite(name = "py_dist_info_tests") + +py_dist_info( + name = "sample_pkg_dist_info", + package_name = "sample_pkg", + entry_points = { + "sample_pkg.plugins": [ + "default_plugin = tests.py_dist_info.sample_pkg:run_plugin", + ], + }, + version = "3.4.5", +) + +py_library( + name = "sample_pkg", + srcs = ["sample_pkg.py"], + data = [":sample_pkg_dist_info"], +) + +py_test( + name = "py_dist_info_runtime_test", + size = "small", + srcs = ["py_dist_info_runtime_test.py"], + deps = [":sample_pkg"], +) diff --git a/tests/py_dist_info/py_dist_info_runtime_test.py b/tests/py_dist_info/py_dist_info_runtime_test.py new file mode 100644 index 0000000000..f8f0893141 --- /dev/null +++ b/tests/py_dist_info/py_dist_info_runtime_test.py @@ -0,0 +1,24 @@ +import importlib.metadata +import unittest + + +class PyDistInfoRuntimeTest(unittest.TestCase): + def test_version_and_metadata(self): + self.assertEqual(importlib.metadata.version("sample_pkg"), "3.4.5") + meta = importlib.metadata.metadata("sample_pkg") + self.assertEqual(meta["Name"], "sample_pkg") + self.assertEqual(meta["Version"], "3.4.5") + + def test_entry_points(self): + eps = importlib.metadata.entry_points( + group="sample_pkg.plugins", + name="default_plugin", + ) + ep_list = list(eps) + self.assertEqual(len(ep_list), 1) + fn = ep_list[0].load() + self.assertEqual(fn(), "plugin_ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/py_dist_info/py_dist_info_tests.bzl b/tests/py_dist_info/py_dist_info_tests.bzl new file mode 100644 index 0000000000..17a4d15658 --- /dev/null +++ b/tests/py_dist_info/py_dist_info_tests.bzl @@ -0,0 +1,97 @@ +"""Tests for py_dist_info.""" + +load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") +load("@rules_testing//lib:util.bzl", rt_util = "util") +load("//python:py_dist_info.bzl", "py_dist_info") + +_tests = [] + +def _test_basic_dist_info(name): + rt_util.helper_target( + py_dist_info, + name = name + "_subject", + package_name = "my_pkg", + version = "1.2.3", + ) + analysis_test( + name = name, + impl = _test_basic_dist_info_impl, + target = name + "_subject", + ) + +def _test_basic_dist_info_impl(env, target): + target_subject = env.expect.that_target(target) + target_subject.default_outputs().contains_exactly([]) + target_subject.runfiles().contains_exactly([ + "{workspace}/my_pkg.dist-info/METADATA", + "{workspace}/my_pkg.dist-info/entry_points.txt", + "{workspace}/{package}/{name}_METADATA", + "{workspace}/{package}/{name}_entry_points.txt", + ]) + + metadata_action = target_subject.action_generating( + "{package}/{name}_METADATA", + ) + metadata_action.content().equals( + "Metadata-Version: 2.1\nName: my_pkg\nVersion: 1.2.3\n", + ) + + entry_points_action = target_subject.action_generating( + "{package}/{name}_entry_points.txt", + ) + entry_points_action.content().equals("") + +_tests.append(_test_basic_dist_info) + +def _test_entry_points_and_target_path(name): + rt_util.helper_target( + py_dist_info, + name = name + "_subject", + entry_points = { + "console_scripts": [ + "my_cli = my_pkg.cli:main", + ], + "pytest11": [ + "my_plugin = my_pkg.plugin", + ], + }, + package_name = "my_pkg", + target_path = "src/subpath", + version = "2.0.0", + ) + analysis_test( + name = name, + impl = _test_entry_points_and_target_path_impl, + target = name + "_subject", + ) + +def _test_entry_points_and_target_path_impl(env, target): + target_subject = env.expect.that_target(target) + target_subject.default_outputs().contains_exactly([]) + target_subject.runfiles().contains_exactly([ + "{workspace}/src/subpath/my_pkg.dist-info/METADATA", + "{workspace}/src/subpath/my_pkg.dist-info/entry_points.txt", + "{workspace}/{package}/{name}_METADATA", + "{workspace}/{package}/{name}_entry_points.txt", + ]) + + entry_points_action = target_subject.action_generating( + "{package}/{name}_entry_points.txt", + ) + entry_points_action.content().equals( + "[console_scripts]\nmy_cli = my_pkg.cli:main\n\n" + + "[pytest11]\nmy_plugin = my_pkg.plugin\n", + ) + +_tests.append(_test_entry_points_and_target_path) + +def py_dist_info_test_suite(name): + """Creates the test suite for py_dist_info. + + Args: + name: The name of the test suite. + """ + test_suite( + name = name, + tests = _tests, + ) diff --git a/tests/py_dist_info/sample_pkg.py b/tests/py_dist_info/sample_pkg.py new file mode 100644 index 0000000000..a6dccbfca3 --- /dev/null +++ b/tests/py_dist_info/sample_pkg.py @@ -0,0 +1,2 @@ +def run_plugin(): + return "plugin_ok" From a8cb1d0d005ca623b7eb125ad7be21f7c176b11c Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 26 Oct 2026 21:52:22 +0000 Subject: [PATCH 2/2] refactor(packaging): implement py_wheel dist-info via py_dist_info Previously, `py_wheel` constructed `.dist-info` metadata (`metadata.txt` and `entry_points.txt`) directly inside `_py_wheel_impl`, duplicating the metadata generation logic needed by `py_dist_info`. This expands `py_dist_info` to accept the full set of `.dist-info` metadata attributes (`distribution`, `version`, `author`, `classifiers`, `description_file`, `entry_points`, `extra_distinfo_files`, `extra_requires`, `requires`, `stamp`, etc.) and return a `PyDistInfo` provider alongside its runfiles `.dist-info` directory. `py_wheel` now accepts a `dist_info` attribute (defaulting to an implicit `.dist_info` target generated by the `py_wheel` macro when metadata attributes are passed directly to `py_wheel`), and fails during analysis if an explicit `dist_info` target is combined with direct `.dist-info` attributes on `py_wheel`. --- news/{py-dist-info.added.md => 4357.added.md} | 3 +- python/BUILD.bazel | 1 + python/features.bzl | 1 + python/packaging.bzl | 27 + python/private/BUILD.bazel | 9 +- python/private/py_dist_info.bzl | 587 +++++++++++++++--- python/private/py_wheel.bzl | 515 +++++---------- python/py_dist_info.bzl | 5 +- tests/py_dist_info/BUILD.bazel | 16 +- .../py_dist_info/py_dist_info_runtime_test.py | 30 + tests/py_dist_info/py_dist_info_tests.bzl | 31 +- tests/py_wheel/py_wheel_tests.bzl | 111 +++- 12 files changed, 867 insertions(+), 469 deletions(-) rename news/{py-dist-info.added.md => 4357.added.md} (67%) diff --git a/news/py-dist-info.added.md b/news/4357.added.md similarity index 67% rename from news/py-dist-info.added.md rename to news/4357.added.md index 4847e13f17..f79a78694e 100644 --- a/news/py-dist-info.added.md +++ b/news/4357.added.md @@ -1,4 +1,5 @@ (rules) Added {obj}`py_dist_info` in `@rules_python//python:py_dist_info.bzl` to generate `.dist-info` metadata (`METADATA` and `entry_points.txt`) in runfiles so `importlib.metadata` can discover package versions and entry points -at runtime. +at runtime, and added `dist_info` support to {obj}`py_wheel` so wheels can +reuse a {obj}`py_dist_info` target. diff --git a/python/BUILD.bazel b/python/BUILD.bazel index 275efe0817..abeba96998 100644 --- a/python/BUILD.bazel +++ b/python/BUILD.bazel @@ -209,6 +209,7 @@ bzl_library( deps = [ ":py_binary", "//python/private:bzlmod_enabled", + "//python/private:py_dist_info", "//python/private:py_package", "//python/private:py_wheel", "//python/private:util", diff --git a/python/features.bzl b/python/features.bzl index a695a13b8a..ef643add00 100644 --- a/python/features.bzl +++ b/python/features.bzl @@ -143,6 +143,7 @@ _LOADABLE_SYMBOLS = { ], "//python:py_dist_info.bzl": [ # keep sorted + "PyDistInfo", "py_dist_info", ], "//python:py_info.bzl": [ diff --git a/python/packaging.bzl b/python/packaging.bzl index 1ec1d380df..bde6406a5d 100644 --- a/python/packaging.bzl +++ b/python/packaging.bzl @@ -17,6 +17,11 @@ load("@bazel_skylib//rules:native_binary.bzl", "native_binary") load("//python:py_binary.bzl", "py_binary") load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") +load( + "//python/private:py_dist_info.bzl", + "DIST_INFO_ATTR_NAMES", + "py_dist_info", +) load("//python/private:py_package.bzl", "py_package_lib") load("//python/private:py_wheel.bzl", _PyWheelInfo = "PyWheelInfo", _py_wheel = "py_wheel") load("//python/private:util.bzl", "copy_propagating_kwargs") @@ -169,6 +174,12 @@ def py_wheel( //path/to:my_wheel.publish --repository testpypi ``` + :::{versionchanged} VERSION_NEXT_FEATURE + Added support for passing a {obj}`py_dist_info` target via `dist_info`, and + automatically generates `.dist_info` when `dist_info` is not + specified. + ::: + Args: name: A unique name for this target. twine: A label of the external location of the py_library target for twine @@ -190,6 +201,22 @@ def py_wheel( **copy_propagating_kwargs(kwargs) ) + if "dist_info" not in kwargs or kwargs["dist_info"] == None: + dist_info_target = "{}.dist_info".format(name) + dist_info_kwargs = { + attr_name: kwargs[attr_name] + for attr_name in DIST_INFO_ATTR_NAMES + if attr_name in kwargs + } + if "toolchains" in kwargs: + dist_info_kwargs["toolchains"] = kwargs["toolchains"] + py_dist_info( + name = dist_info_target, + tags = manual_tags, + **copy_propagating_kwargs(kwargs, dist_info_kwargs) + ) + kwargs["dist_info"] = dist_info_target + _py_wheel( name = name, tags = tags, diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel index fa87d9ba9f..d52841fed1 100644 --- a/python/private/BUILD.bazel +++ b/python/private/BUILD.bazel @@ -729,10 +729,10 @@ bzl_library( srcs = ["py_wheel.bzl"], deps = [ ":attributes", + ":py_dist_info", ":py_info", ":py_package", ":rule_builders", - ":stamp_impl", ":toolchain_types", ":transition_labels", ":version", @@ -998,6 +998,13 @@ bzl_library( bzl_library( name = "py_dist_info", srcs = ["py_dist_info.bzl"], + deps = [ + ":attributes", + ":rule_builders", + ":stamp_impl", + ":toolchain_types", + ":transition_labels", + ], ) bzl_library( diff --git a/python/private/py_dist_info.bzl b/python/private/py_dist_info.bzl index dbb9606950..8e8daf0251 100644 --- a/python/private/py_dist_info.bzl +++ b/python/private/py_dist_info.bzl @@ -1,60 +1,498 @@ """Implementation of the py_dist_info rule.""" -_METADATA_TEMPLATE = """\ -Metadata-Version: 2.1 -Name: {name} -Version: {version} -""" +load(":attributes.bzl", "CONFIG_SETTINGS_ATTR", "apply_config_settings_attr") +load(":rule_builders.bzl", "ruleb") +load(":stamp_impl.bzl", "is_stamping_enabled") +load(":toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE") +load(":transition_labels.bzl", "TRANSITION_LABELS") + +PyDistInfo = provider( + doc = """ +Information about `.dist-info` metadata produced by {rule}`py_dist_info_rule`. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + fields = { + "description_file": """ +File | None: Optional file containing the package description. +""", + "distribution": "str: Name of the distribution.", + "entry_points_file": """ +File | None: Optional generated `entry_points.txt` file. +""", + "extra_distinfo_files": """ +dict[str, File]: Mapping of `.dist-info`-relative filenames to files to include +in the `.dist-info` directory. +""", + "metadata_file": """ +File: Preliminary `metadata.txt` file consumed by `py_wheel`. +""", + "metadata_inputs": """ +depset[File]: Additional input files referenced by `metadata_file` (such as +`requires_file` or `extra_requires_files`). +""", + "stamping_enabled": """ +bool: Whether workspace status stamping is enabled for this distribution +metadata. +""", + "version": """ +str: Version string of the distribution (with Make variables expanded). +""", + }, +) + +_PROJECT_URL_LABEL_LENGTH_LIMIT = 32 +_DESCRIPTION_FILE_EXTENSION_TO_TYPE = { + "md": "text/markdown", + "rst": "text/x-rst", +} +_DEFAULT_DESCRIPTION_FILE_TYPE = "text/plain" + +DIST_INFO_ATTR_NAMES = ( + "author", + "author_email", + "classifiers", + "console_scripts", + "description_content_type", + "description_file", + "distribution", + "entry_points", + "extra_distinfo_files", + "extra_requires", + "extra_requires_files", + "homepage", + "license", + "project_urls", + "python_requires", + "requires", + "requires_file", + "stamp", + "summary", + "version", +) + +def replace_make_variables(flag, ctx): + """Replaces `$(VAR)` Make variables in `flag` using `ctx.var`. + + Args: + flag: The string in which to expand Make variables. + ctx: The rule context containing `ctx.var`. + + Returns: + str: The input string with Make variables expanded. + """ + if "$" in flag: + for varname, varsub in ctx.var.items(): + flag = flag.replace("$(%s)" % varname, varsub) + return flag + +def create_dist_info_attrs(*, distribution_mandatory): + """Creates the attribute dictionary for `.dist-info` metadata. + + Args: + distribution_mandatory: Whether `distribution` and `version` are + mandatory attributes on the rule. + + Returns: + dict[str, attr]: Dictionary of `.dist-info` attributes. + """ + return { + "author": attr.string( + default = "", + doc = "A string specifying the author of the package.", + ), + "author_email": attr.string( + default = "", + doc = """ +A string specifying the email address of the package author. +""", + ), + "classifiers": attr.string_list( + doc = """ +A list of strings describing the categories for the package. For valid +classifiers see +""", + ), + "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. +""", + ), + "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 + +""", + ), + "description_file": attr.label( + allow_single_file = True, + doc = "A file containing text describing the package.", + ), + "distribution": attr.string( + mandatory = distribution_mandatory, + doc = """ +Name of the distribution. + +This should match the project name on 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 + +""", + ), + "entry_points": attr.string_list_dict( + doc = """ +`entry_points`, e.g. `{'console_scripts': ['main = examples.wheel.main:main']}`. +""", + ), + "extra_distinfo_files": attr.label_keyed_string_dict( + allow_files = True, + doc = "Extra files to add to distinfo directory in the archive.", + ), + "extra_requires": attr.string_list_dict( + doc = """ +A mapping of [extras](https://peps.python.org/pep-0508/#extras) options to +lists of requirements (similar to `requires`). This attribute is mutually +exclusive with `extra_requires_files`. +""", + ), + "extra_requires_files": attr.label_keyed_string_dict( + allow_files = True, + doc = """ +A mapping of requirements files (similar to `requires_file`) to the name of an +[extras](https://peps.python.org/pep-0508/#extras) option. This attribute is +mutually exclusive with `extra_requires`. +""", + ), + "homepage": attr.string( + default = "", + doc = "A string specifying the URL for the package homepage.", + ), + "license": attr.string( + default = "", + doc = "A string specifying the license of the package.", + ), + "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( + default = "", + doc = """ +Python versions required by this distribution, e.g. '>=3.5,<3.7'. + +Supports make variables, e.g. `python_requires = "==$(PY_MAJOR_DOT_MINOR).*"`. +See {rule}`py_runtime_pair` for variables provided by the Python toolchain. + +:::{versionchanged} VERSION_NEXT_FEATURE +Supports make variables. +::: +""", + ), + "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. This attribute is +mutually exclusive with `requires_file`. +""", + ), + "requires_file": attr.label( + allow_single_file = True, + 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`. +""", + ), + "stamp": attr.int( + default = -1, + doc = """ +Whether to encode build information into the distribution metadata. 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. +""", + values = [1, 0, -1], + ), + "summary": attr.string( + doc = "A one-line summary of what the distribution does", + ), + "version": attr.string( + mandatory = distribution_mandatory, + 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`](#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`](#py_wheel_dist) for more info. +""", + ), + "_stamp_flag": attr.label( + default = Label("//python/private:stamp"), + doc = """ +A setting used to determine whether or not the `--stamp` flag is enabled. +""", + ), + } def _py_dist_info_rule_impl(ctx): - package_name = ctx.attr.package_name - if not package_name: - fail("package_name attribute is required") - version = ctx.attr.version - if not version: - fail("version attribute is required") - - # Prepare entry_points.txt content - entry_points_lines = [] - for group, points in ctx.attr.entry_points.items(): - entry_points_lines.append("[%s]" % group) - entry_points_lines.extend(points) - entry_points_lines.append("") - entry_points_content = "\n".join(entry_points_lines) - - # Use unique names for staging files to avoid clashes - entry_points_staging_file = ctx.actions.declare_file( - ctx.label.name + "_entry_points.txt", + prefix = ctx.label.name + distribution = ctx.attr.distribution + if not distribution: + fail("`distribution` attribute is required") + if not ctx.attr.version: + fail("`version` attribute is required") + + python_requires = replace_make_variables(ctx.attr.python_requires, ctx) + version_str = replace_make_variables(ctx.attr.version, ctx) + + metadata_inputs = [] + + common_metadata_lines = [] + if ctx.attr.author: + common_metadata_lines.append("Author: %s" % ctx.attr.author) + if ctx.attr.author_email: + common_metadata_lines.append("Author-email: %s" % ctx.attr.author_email) + if ctx.attr.homepage: + common_metadata_lines.append("Home-page: %s" % ctx.attr.homepage) + if ctx.attr.license: + common_metadata_lines.append("License: %s" % ctx.attr.license) + if ctx.attr.description_content_type: + common_metadata_lines.append( + "Description-Content-Type: %s" % ctx.attr.description_content_type, + ) + elif ctx.attr.description_file: + description_file_type = _DESCRIPTION_FILE_EXTENSION_TO_TYPE.get( + ctx.file.description_file.extension, + _DEFAULT_DESCRIPTION_FILE_TYPE, + ) + common_metadata_lines.append( + "Description-Content-Type: %s" % description_file_type, + ) + if ctx.attr.summary: + common_metadata_lines.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. ".format( + len(label), + ) + "It is limited to {} characters.".format( + _PROJECT_URL_LABEL_LENGTH_LIMIT, + ), + ) + common_metadata_lines.append("Project-URL: %s, %s" % (label, url)) + + for c in ctx.attr.classifiers: + common_metadata_lines.append("Classifier: %s" % c) + + if python_requires: + common_metadata_lines.append("Requires-Python: %s" % python_requires) + + if ctx.attr.requires and ctx.attr.requires_file: + fail( + "`requires` and `requires_file` are mutually exclusive. " + + "Please update {}".format(ctx.label), + ) + + for req in ctx.attr.requires: + common_metadata_lines.append("Requires-Dist: %s" % req) + + 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), + ) + + # Note: Description file and version are not embedded into metadata.txt yet + # for wheelmaker consumption; wheelmaker normalizes the version, expands @ + # requirement files, and appends the description. + metadata_file = ctx.actions.declare_file(prefix + ".metadata.txt") + metadata_contents = [ + "Metadata-Version: 2.1", + "Name: %s" % distribution, + ] + common_metadata_lines + + if ctx.attr.requires_file: + # The @ prefixed paths will be resolved by the PyWheel action, + # expanding each line containing a constraint in place of this + # directive. + metadata_contents.append( + "Requires-Dist: @%s" % ctx.file.requires_file.path, + ) + metadata_inputs.append(ctx.file.requires_file) + + extra_requires_lines = [] + for option, option_requirements in sorted(ctx.attr.extra_requires.items()): + extra_requires_lines.append("Provides-Extra: %s" % option) + for requirement in option_requirements: + extra_requires_lines.append( + "Requires-Dist: %s; extra == '%s'" % (requirement, option), + ) + metadata_contents.extend(extra_requires_lines) + + extra_requires_files = {} + for option_target, option in ctx.attr.extra_requires_files.items(): + if option in extra_requires_files: + fail( + "Duplicate `extra_requires_files` option '{}' ".format(option) + + "found on target {}".format(ctx.label), + ) + option_requires_files = option_target[DefaultInfo].files.to_list() + if len(option_requires_files) != 1: + fail( + "Labels in `extra_requires_files` must result in a single " + + "file, but {label} provides {files} from {owner}".format( + label = ctx.label, + files = option_requires_files, + owner = option_target.label, + ), + ) + extra_requires_files[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, + ), + ) + metadata_inputs.append(option_requires_file) + + ctx.actions.write( + output = metadata_file, + content = "\n".join(metadata_contents) + "\n", ) - ctx.actions.write(entry_points_staging_file, entry_points_content) - # Prepare METADATA content - metadata_content = _METADATA_TEMPLATE.format( - name = package_name, - version = version, + # Merge console_scripts into entry_points. + entrypoints = dict(ctx.attr.entry_points) + if ctx.attr.console_scripts: + console_scripts = list(entrypoints.get("console_scripts", [])) + entrypoints["console_scripts"] = console_scripts + for script_name, ref in ctx.attr.console_scripts.items(): + console_scripts.append( + "{name} = {ref}".format(name = script_name, ref = ref), + ) + + entry_points_file = None + if entrypoints: + lines = [] + for group, entries in sorted(entrypoints.items()): + if lines: + lines.append("") + lines.append("[{group}]".format(group = group)) + lines += sorted(entries) + entry_points_file = ctx.actions.declare_file( + prefix + "_entry_points.txt", + ) + ctx.actions.write( + output = entry_points_file, + content = "\n".join(lines), + ) + + extra_distinfo_files = {} + for target, filename in ctx.attr.extra_distinfo_files.items(): + target_files = target[DefaultInfo].files.to_list() + if len(target_files) != 1: + fail( + "Multi-file target listed in extra_distinfo_files %s", + filename, + ) + extra_distinfo_files[filename] = target_files[0] + + dist_info = PyDistInfo( + distribution = distribution, + version = version_str, + metadata_file = metadata_file, + metadata_inputs = depset(metadata_inputs), + entry_points_file = entry_points_file, + description_file = ctx.file.description_file, + extra_distinfo_files = extra_distinfo_files, + stamping_enabled = is_stamping_enabled(ctx.attr), ) - metadata_staging_file = ctx.actions.declare_file( - ctx.label.name + "_METADATA", + + # Write the runtime METADATA file with Version included so + # importlib.metadata can discover the distribution version in runfiles. + runtime_metadata_file = ctx.actions.declare_file(prefix + "_METADATA") + runtime_metadata_lines = ( + [ + "Metadata-Version: 2.1", + "Name: %s" % dist_info.distribution, + "Version: %s" % dist_info.version, + ] + + common_metadata_lines + + extra_requires_lines + ) + + ctx.actions.write( + output = runtime_metadata_file, + content = "\n".join(runtime_metadata_lines) + "\n", ) - ctx.actions.write(metadata_staging_file, metadata_content) # Create runfiles with the .dist-info directory structure target_path = ctx.attr.target_path path_components = [ctx.label.repo_name or ctx.workspace_name] if target_path: path_components.append(target_path) - path_components.append(package_name + ".dist-info") + path_components.append(dist_info.distribution + ".dist-info") dist_info_dirname = "/".join(path_components) - metadata_symlink_path = dist_info_dirname + "/METADATA" - entry_points_symlink_path = dist_info_dirname + "/entry_points.txt" + runfiles_files = [runtime_metadata_file] + root_symlinks = { + dist_info_dirname + "/METADATA": runtime_metadata_file, + } + if dist_info.entry_points_file: + runfiles_files.append(dist_info.entry_points_file) + root_symlinks[dist_info_dirname + "/entry_points.txt"] = ( + dist_info.entry_points_file + ) + for filename, file in dist_info.extra_distinfo_files.items(): + runfiles_files.append(file) + root_symlinks[dist_info_dirname + "/" + filename] = file runfiles = ctx.runfiles( - files = [entry_points_staging_file, metadata_staging_file], - root_symlinks = { - metadata_symlink_path: metadata_staging_file, - entry_points_symlink_path: entry_points_staging_file, - }, + files = runfiles_files, + root_symlinks = root_symlinks, ) return [ @@ -64,34 +502,17 @@ def _py_dist_info_rule_impl(ctx): # locations (ones not on sys.path) wouldn't work. runfiles = runfiles, ), + dist_info, ] -py_dist_info_rule = rule( - implementation = _py_dist_info_rule_impl, - doc = """ -Generates `.dist-info` package metadata in runfiles for `importlib.metadata`. +def _transition_dist_info_impl(settings, attr): + """Transition for py_dist_info.""" + return apply_config_settings_attr(dict(settings), attr) -This rule generates a `.dist-info` directory containing `METADATA` -and `entry_points.txt` files in the runfiles tree so Python's -`importlib.metadata` can discover package versions and entry points at runtime. - -:::{versionadded} VERSION_NEXT_FEATURE -::: -""", - attrs = { - "entry_points": attr.string_list_dict( - doc = """ -A dictionary mapping entry point group names to lists of entry point -specifications (e.g. `{"console_scripts": ["my_cli = my_pkg.cli:main"]}`). -""", - ), - "package_name": attr.string( - mandatory = True, - doc = """ -The distribution package name written to `METADATA` and used for the -`.dist-info` directory name. -""", - ), +_py_dist_info_attrs = ( + create_dist_info_attrs(distribution_mandatory = True) | + CONFIG_SETTINGS_ATTR | + { "target_path": attr.string( default = "", doc = """ @@ -100,23 +521,43 @@ in runfiles. If set, this directory must be on `sys.path` (for example via the `imports` attribute of a `py_library`) for `importlib.metadata` to discover it. """, ), - "version": attr.string( - mandatory = True, - doc = """ -The distribution package version written to `METADATA`. -""", - ), - }, + } ) +py_dist_info_rule = ruleb.Rule( + implementation = _py_dist_info_rule_impl, + doc = """ +Generates `.dist-info` package metadata for `importlib.metadata` and `py_wheel`. + +This rule generates a `.dist-info` directory in the runfiles tree +so Python's `importlib.metadata` can discover package versions, metadata, and +entry points at runtime, and provides {obj}`PyDistInfo` so {obj}`py_wheel` can +reuse the same `.dist-info` definition. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + attrs = _py_dist_info_attrs, + cfg = transition( + implementation = _transition_dist_info_impl, + inputs = TRANSITION_LABELS, + outputs = TRANSITION_LABELS, + ), + provides = [PyDistInfo], + toolchains = [ + ruleb.ToolchainType(TARGET_TOOLCHAIN_TYPE, mandatory = False), + ], +).build() + def py_dist_info(**kwargs): - """Declares Python package metadata for `importlib.metadata`. + """Declares Python `.dist-info` package metadata. This macro wraps {rule}`py_dist_info_rule` and generates the necessary files for Python's `importlib.metadata` to discover package information, including versions and entry points, at runtime. Include the `py_dist_info` target in the `data` attribute of the corresponding {obj}`py_library`, - {obj}`py_binary`, or {obj}`py_test`. + {obj}`py_binary`, or {obj}`py_test`, or pass it via the `dist_info` + attribute of {obj}`py_wheel`. :::{versionadded} VERSION_NEXT_FEATURE ::: diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl index c44623f2f7..50cc5be364 100644 --- a/python/private/py_wheel.bzl +++ b/python/private/py_wheel.bzl @@ -15,10 +15,16 @@ "Implementation of py_wheel rule" load(":attributes.bzl", "CONFIG_SETTINGS_ATTR", "apply_config_settings_attr") +load( + ":py_dist_info.bzl", + "DIST_INFO_ATTR_NAMES", + "PyDistInfo", + "create_dist_info_attrs", + "replace_make_variables", +) load(":py_info.bzl", "PyInfo") load(":py_package.bzl", "py_package_lib") load(":rule_builders.bzl", "ruleb") -load(":stamp_impl.bzl", "is_stamping_enabled") load(":toolchain_types.bzl", "TARGET_TOOLCHAIN_TYPE") load(":transition_labels.bzl", "TRANSITION_LABELS") load(":version.bzl", "version") @@ -34,7 +40,9 @@ PyWheelInfo = provider( }, ) -_distribution_attrs = { +ALLOWED_DATA_FILE_PREFIX = ("purelib", "platlib", "headers", "scripts", "data") + +_wheel_only_attrs = { "abi": attr.string( default = "none", doc = """ @@ -49,154 +57,6 @@ default. ::: """, ), - "compress": attr.bool( - default = True, - doc = "Enable compression of the final archive.", - ), - "distribution": attr.string( - mandatory = True, - doc = """\ -Name of the distribution. - -This should match the project name on 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 -""", - ), - "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", -}) -` - -Supports make variables. - -:::{versionchanged} VERSION_NEXT_FEATURE -Supports make variables. -::: -""", - ), - "python_tag": attr.string( - default = "py3", - doc = """ -Supported Python version(s), eg `py3`, `cp35.cp36`, etc. - -Supports make variables, e.g. `python_tag = "$(PY_WHEEL_PYTHON_TAG)"`. See -{rule}`py_runtime_pair` for variables provided by the Python toolchain. - -:::{versionchanged} VERSION_NEXT_FEATURE -Python toolchain make variables (such as `$(PY_WHEEL_PYTHON_TAG)`) are available -by default. -::: -""", - ), - "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`](#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`](#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"), - ), -} - -_feature_flags = {} - -ALLOWED_DATA_FILE_PREFIX = ("purelib", "platlib", "headers", "scripts", "data") -_requirement_attrs = { - "extra_requires": attr.string_list_dict( - doc = ("A mapping of [extras](https://peps.python.org/pep-0508/#extras) options to lists of requirements (similar to `requires`). This attribute " + - "is mutually exclusive with `extra_requires_file`."), - ), - "extra_requires_files": attr.label_keyed_string_dict( - doc = ("A mapping of requirements files (similar to `requires_file`) to the name of an [extras](https://peps.python.org/pep-0508/#extras) option " + - "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. 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, - ), -} - -_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 = { "add_path_prefix": attr.string( default = "", doc = """\ @@ -214,16 +74,9 @@ The {attr}`add_path_prefix` attribute was added. ::: """, ), - "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 ", + "compress": attr.bool( + default = True, + doc = "Enable compression of the final archive.", ), "data_files": attr.label_keyed_string_dict( doc = (""" @@ -257,44 +110,72 @@ be moved under that directory. """.format(prefixes = ALLOWED_DATA_FILE_PREFIX)), allow_files = True, ), - "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 "), - ), - "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 = "", + "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. +""", ), - "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/"}}`'), + "dist_info": attr.label( + doc = """ +A {obj}`py_dist_info` target providing `.dist-info` metadata for the wheel. + +If not provided, the `py_wheel` macro automatically constructs a `py_dist_info` +target from the distribution metadata attributes passed to `py_wheel`. +If `dist_info` is specified, passing any of the distribution metadata attributes +directly to `py_wheel` is an error. + +:::{versionadded} VERSION_NEXT_FEATURE +::: +""", + providers = [PyDistInfo], ), - "python_requires": attr.string( + "platform": attr.string( + default = "any", doc = """ -Python versions required by this distribution, e.g. '>=3.5,<3.7'. +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: -Supports make variables, e.g. `python_requires = "==$(PY_MAJOR_DOT_MINOR).*"`. -See {rule}`py_runtime_pair` for variables provided by the Python toolchain. +` +platform = select({ + "//platforms:windows_x86_64": "win_amd64", + "//platforms:macos_x86_64": "macosx_10_7_x86_64", + "//platforms:linux_x86_64": "manylinux2014_x86_64", +}) +` + +Supports make variables. :::{versionchanged} VERSION_NEXT_FEATURE Supports make variables. ::: """, - default = "", + ), + "python_tag": attr.string( + default = "py3", + doc = """ +Supported Python version(s), eg `py3`, `cp35.cp36`, etc. + +Supports make variables, e.g. `python_tag = "$(PY_WHEEL_PYTHON_TAG)"`. See +{rule}`py_runtime_pair` for variables provided by the Python toolchain. + +:::{versionchanged} VERSION_NEXT_FEATURE +Python toolchain make variables (such as `$(PY_WHEEL_PYTHON_TAG)`) are available +by default. +::: +""", ), "strip_path_prefixes": attr.string_list( default = [], @@ -308,18 +189,13 @@ For example: `"foo/file2.py"` to `"file2.py"` """, ), - "summary": attr.string( - doc = "A one-line summary of what the distribution does", + "_wheelmaker": attr.label( + executable = True, + cfg = "exec", + default = "//tools:wheelmaker", ), } -_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_distribution_name(name): """Escape the distribution name component of a filename. @@ -381,26 +257,51 @@ def _escape_filename_segment(segment): 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 _has_dist_info_attr(ctx, attr_name): + value = getattr(ctx.attr, attr_name) + if attr_name == "stamp": + return value != -1 + return bool(value) + def _py_wheel_impl(ctx): - abi = _replace_make_variables(ctx.attr.abi, ctx) - platform = _replace_make_variables(ctx.attr.platform, ctx) - python_requires = _replace_make_variables(ctx.attr.python_requires, ctx) - python_tag = _replace_make_variables(ctx.attr.python_tag, ctx) - version_str = _replace_make_variables(ctx.attr.version, ctx) + if not ctx.attr.dist_info: + fail("`dist_info` attribute is required on {}".format(ctx.label)) + + # If the user passed `dist_info` explicitly rather than using the implicit + # `.dist_info` target created by the `py_wheel` macro, reject any + # direct `.dist-info` metadata attributes on `py_wheel`. + implicit_dist_info_name = ctx.label.name + ".dist_info" + is_implicit_dist_info = ( + ctx.attr.dist_info.label.package == ctx.label.package and + ctx.attr.dist_info.label.name == implicit_dist_info_name + ) + if not is_implicit_dist_info: + conflicting_attrs = [ + attr_name + for attr_name in DIST_INFO_ATTR_NAMES + if _has_dist_info_attr(ctx, attr_name) + ] + if conflicting_attrs: + fail( + "Cannot specify {} together with `dist_info` on {}".format( + ", ".join(["`{}`".format(a) for a in conflicting_attrs]), + ctx.label, + ), + ) + + dist_info = ctx.attr.dist_info[PyDistInfo] + abi = replace_make_variables(ctx.attr.abi, ctx) + platform = replace_make_variables(ctx.attr.platform, ctx) + python_tag = replace_make_variables(ctx.attr.python_tag, ctx) + distribution = dist_info.distribution + version_str = dist_info.version filename_segments = [ - _escape_filename_distribution_name(ctx.attr.distribution), + _escape_filename_distribution_name(distribution), version.normalize(version_str), _escape_filename_segment(python_tag), _escape_filename_segment(abi), @@ -420,13 +321,13 @@ def _py_wheel_impl(ctx): direct = ctx.files.deps + direct_pyi_files, ) - # 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, deferring # depset expansion to execution time via Args.add_all with map_each. - packageinputfile = ctx.actions.declare_file(ctx.attr.name + "_target_wrapped_inputs.txt") + packageinputfile = ctx.actions.declare_file( + ctx.attr.name + "_target_wrapped_inputs.txt", + ) package_args = ctx.actions.args() package_args.set_param_file_format("multiline") package_args.add_all(inputs_to_package, map_each = _input_file_to_arg) @@ -434,155 +335,46 @@ def _py_wheel_impl(ctx): other_inputs.append(packageinputfile) args = ctx.actions.args() - args.add("--name", ctx.attr.distribution) + args.add("--name", distribution) args.add("--version", version_str) args.add("--python_tag", python_tag) args.add("--abi", abi) args.add("--platform", 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") + args.add_all( + ctx.attr.strip_path_prefixes, + format_each = "--strip_path_prefix=%s", + ) args.add("--path_prefix", ctx.attr.add_path_prefix) # Pass workspace status files if stamping is enabled - if is_stamping_enabled(ctx.attr): + if dist_info.stamping_enabled: 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 python_requires: - metadata_contents.append("Requires-Python: %s" % python_requires) - - 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: - # The @ prefixed paths will be resolved by the PyWheel action. - # Expanding each line containing a constraint in place of this - # directive. - 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("Labels in `extra_requires_files` must result in a single file, but {label} provides {files} from {owner}".format( - label = ctx.label, - files = option_requires_files, - owner = option_requires_target.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( - # The @ prefixed paths will be resolved by the PyWheel action. - # Expanding each line containing a constraint in place of this - # directive and appending the extra option. - "Requires-Dist: @%s; extra == '%s'" % (option_requires_file.path, option), - ) - other_inputs.append(option_requires_file) + other_inputs.append(dist_info.metadata_file) + args.add("--metadata_file", dist_info.metadata_file) - 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) + if dist_info.entry_points_file: + other_inputs.append(dist_info.entry_points_file) + args.add("--entry_points_file", dist_info.entry_points_file) + + if dist_info.description_file: + args.add("--description_file", dist_info.description_file) + other_inputs.append(dist_info.description_file) if not ctx.attr.compress: args.add("--no_compress") - for target, filename in ctx.attr.extra_distinfo_files.items(): - target_files = target[DefaultInfo].files.to_list() - if len(target_files) != 1: - fail( - "Multi-file target listed in extra_distinfo_files %s", - filename, - ) - other_inputs.extend(target_files) + for filename, file in dist_info.extra_distinfo_files.items(): + other_inputs.append(file) args.add( "--extra_distinfo_file", - filename + ";" + target_files[0].path, + filename + ";" + file.path, ) for target, filename in ctx.attr.data_files.items(): @@ -613,7 +405,10 @@ def _py_wheel_impl(ctx): ctx.actions.run( mnemonic = "PyWheel", - inputs = depset(direct = other_inputs, transitive = [inputs_to_package]), + inputs = depset( + direct = other_inputs, + transitive = [inputs_to_package, dist_info.metadata_inputs], + ), outputs = [outfile, name_file], arguments = [args], executable = ctx.executable._wheelmaker, @@ -633,40 +428,12 @@ def _py_wheel_impl(ctx): ), ] -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, - _feature_flags, - _requirement_attrs, - _entrypoint_attrs, - _other_attrs, - CONFIG_SETTINGS_ATTR, + attrs = ( + _wheel_only_attrs | + create_dist_info_attrs(distribution_mandatory = False) | + CONFIG_SETTINGS_ATTR ), ) diff --git a/python/py_dist_info.bzl b/python/py_dist_info.bzl index e6c10a5071..ce3297327f 100644 --- a/python/py_dist_info.bzl +++ b/python/py_dist_info.bzl @@ -2,7 +2,8 @@ Use {obj}`py_dist_info` to generate `.dist-info` metadata (`METADATA` and `entry_points.txt`) in runfiles so that Python's `importlib.metadata` can -discover package versions and entry points at runtime. +discover package versions and entry points at runtime, or to provide reusable +metadata for {obj}`py_wheel`. :::{versionadded} VERSION_NEXT_FEATURE ::: @@ -10,7 +11,9 @@ discover package versions and entry points at runtime. load( "//python/private:py_dist_info.bzl", + _PyDistInfo = "PyDistInfo", _py_dist_info = "py_dist_info", ) +PyDistInfo = _PyDistInfo py_dist_info = _py_dist_info diff --git a/tests/py_dist_info/BUILD.bazel b/tests/py_dist_info/BUILD.bazel index 6fdbbae612..48cab6b383 100644 --- a/tests/py_dist_info/BUILD.bazel +++ b/tests/py_dist_info/BUILD.bazel @@ -1,3 +1,4 @@ +load("//python:packaging.bzl", "py_wheel") load("//python:py_dist_info.bzl", "py_dist_info") load("//python:py_library.bzl", "py_library") load("//python:py_test.bzl", "py_test") @@ -11,12 +12,13 @@ py_dist_info_test_suite(name = "py_dist_info_tests") py_dist_info( name = "sample_pkg_dist_info", - package_name = "sample_pkg", + distribution = "sample_pkg", entry_points = { "sample_pkg.plugins": [ "default_plugin = tests.py_dist_info.sample_pkg:run_plugin", ], }, + summary = "Sample package for testing py_dist_info", version = "3.4.5", ) @@ -26,9 +28,19 @@ py_library( data = [":sample_pkg_dist_info"], ) +py_wheel( + name = "sample_pkg_wheel", + dist_info = ":sample_pkg_dist_info", + deps = [":sample_pkg"], +) + py_test( name = "py_dist_info_runtime_test", size = "small", srcs = ["py_dist_info_runtime_test.py"], - deps = [":sample_pkg"], + data = [":sample_pkg_wheel"], + deps = [ + ":sample_pkg", + "//python/runfiles", + ], ) diff --git a/tests/py_dist_info/py_dist_info_runtime_test.py b/tests/py_dist_info/py_dist_info_runtime_test.py index f8f0893141..eadb4f240c 100644 --- a/tests/py_dist_info/py_dist_info_runtime_test.py +++ b/tests/py_dist_info/py_dist_info_runtime_test.py @@ -1,5 +1,8 @@ import importlib.metadata import unittest +import zipfile + +from python.runfiles import runfiles class PyDistInfoRuntimeTest(unittest.TestCase): @@ -8,6 +11,9 @@ def test_version_and_metadata(self): meta = importlib.metadata.metadata("sample_pkg") self.assertEqual(meta["Name"], "sample_pkg") self.assertEqual(meta["Version"], "3.4.5") + self.assertEqual( + meta["Summary"], "Sample package for testing py_dist_info" + ) def test_entry_points(self): eps = importlib.metadata.entry_points( @@ -19,6 +25,30 @@ def test_entry_points(self): fn = ep_list[0].load() self.assertEqual(fn(), "plugin_ok") + def test_wheel_reuses_dist_info(self): + whl_path = ( + runfiles.CreateOrRaise().root() + / "rules_python/tests/py_dist_info/sample_pkg-3.4.5-py3-none-any.whl" + ) + with zipfile.ZipFile(whl_path) as zf: + metadata = zf.read("sample_pkg-3.4.5.dist-info/METADATA").decode( + "utf-8" + ) + entry_points = zf.read( + "sample_pkg-3.4.5.dist-info/entry_points.txt" + ).decode("utf-8") + + self.assertIn("Name: sample_pkg\n", metadata) + self.assertIn("Version: 3.4.5\n", metadata) + self.assertIn( + "Summary: Sample package for testing py_dist_info\n", metadata + ) + self.assertEqual( + entry_points, + "[sample_pkg.plugins]\n" + "default_plugin = tests.py_dist_info.sample_pkg:run_plugin", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/py_dist_info/py_dist_info_tests.bzl b/tests/py_dist_info/py_dist_info_tests.bzl index 17a4d15658..fa21713111 100644 --- a/tests/py_dist_info/py_dist_info_tests.bzl +++ b/tests/py_dist_info/py_dist_info_tests.bzl @@ -10,7 +10,7 @@ def _test_basic_dist_info(name): rt_util.helper_target( py_dist_info, name = name + "_subject", - package_name = "my_pkg", + distribution = "my_pkg", version = "1.2.3", ) analysis_test( @@ -24,9 +24,7 @@ def _test_basic_dist_info_impl(env, target): target_subject.default_outputs().contains_exactly([]) target_subject.runfiles().contains_exactly([ "{workspace}/my_pkg.dist-info/METADATA", - "{workspace}/my_pkg.dist-info/entry_points.txt", "{workspace}/{package}/{name}_METADATA", - "{workspace}/{package}/{name}_entry_points.txt", ]) metadata_action = target_subject.action_generating( @@ -36,10 +34,12 @@ def _test_basic_dist_info_impl(env, target): "Metadata-Version: 2.1\nName: my_pkg\nVersion: 1.2.3\n", ) - entry_points_action = target_subject.action_generating( - "{package}/{name}_entry_points.txt", + wheel_metadata_action = target_subject.action_generating( + "{package}/{name}.metadata.txt", + ) + wheel_metadata_action.content().equals( + "Metadata-Version: 2.1\nName: my_pkg\n", ) - entry_points_action.content().equals("") _tests.append(_test_basic_dist_info) @@ -47,6 +47,10 @@ def _test_entry_points_and_target_path(name): rt_util.helper_target( py_dist_info, name = name + "_subject", + console_scripts = { + "legacy_cli": "my_pkg.cli:legacy", + }, + distribution = "my_pkg", entry_points = { "console_scripts": [ "my_cli = my_pkg.cli:main", @@ -55,7 +59,7 @@ def _test_entry_points_and_target_path(name): "my_plugin = my_pkg.plugin", ], }, - package_name = "my_pkg", + summary = "My package summary", target_path = "src/subpath", version = "2.0.0", ) @@ -75,12 +79,21 @@ def _test_entry_points_and_target_path_impl(env, target): "{workspace}/{package}/{name}_entry_points.txt", ]) + metadata_action = target_subject.action_generating( + "{package}/{name}_METADATA", + ) + metadata_action.content().equals( + "Metadata-Version: 2.1\nName: my_pkg\nVersion: 2.0.0\n" + + "Summary: My package summary\n", + ) + entry_points_action = target_subject.action_generating( "{package}/{name}_entry_points.txt", ) entry_points_action.content().equals( - "[console_scripts]\nmy_cli = my_pkg.cli:main\n\n" + - "[pytest11]\nmy_plugin = my_pkg.plugin\n", + "[console_scripts]\nlegacy_cli = my_pkg.cli:legacy\n" + + "my_cli = my_pkg.cli:main\n\n" + + "[pytest11]\nmy_plugin = my_pkg.plugin", ) _tests.append(_test_entry_points_and_target_path) diff --git a/tests/py_wheel/py_wheel_tests.bzl b/tests/py_wheel/py_wheel_tests.bzl index 92fb73f6c2..447bac837e 100644 --- a/tests/py_wheel/py_wheel_tests.bzl +++ b/tests/py_wheel/py_wheel_tests.bzl @@ -17,11 +17,18 @@ load("@rules_testing//lib:analysis_test.bzl", "analysis_test", "test_suite") load("@rules_testing//lib:truth.bzl", "matching", "subjects") load("@rules_testing//lib:util.bzl", rt_util = "util") load("//python:packaging.bzl", "py_wheel") +load("//python:py_dist_info.bzl", "py_dist_info") load("//python/private:common_labels.bzl", "labels") # buildifier: disable=bzl-visibility _basic_tests = [] _tests = [] +def _dist_info_target_subject(target): + return target.attr( + "dist_info", + factory = subjects.target, + ) + def _test_metadata(name): rt_util.helper_target( py_wheel, @@ -31,13 +38,17 @@ def _test_metadata(name): ) analysis_test( name = name, + collect_actions_recursively = True, impl = _test_metadata_impl, target = name + "_subject", ) def _test_metadata_impl(env, target): - action = env.expect.that_target(target).action_generating( - "{package}/{name}.metadata.txt", + dist_info_target = _dist_info_target_subject( + env.expect.that_target(target), + ) + action = dist_info_target.action_generating( + "{package}/{name}.dist_info.metadata.txt", ) action.content().split("\n").contains_exactly([ env.expect.meta.format_str("Name: mydist_{test_name}"), @@ -130,13 +141,17 @@ def _test_content_type_from_attr(name): ) analysis_test( name = name, + collect_actions_recursively = True, impl = _test_content_type_from_attr_impl, target = name + "_subject", ) def _test_content_type_from_attr_impl(env, target): - action = env.expect.that_target(target).action_generating( - "{package}/{name}.metadata.txt", + dist_info_target = _dist_info_target_subject( + env.expect.that_target(target), + ) + action = dist_info_target.action_generating( + "{package}/{name}.dist_info.metadata.txt", ) action.content().split("\n").contains( "Description-Content-Type: text/x-rst", @@ -154,13 +169,17 @@ def _test_content_type_from_description(name): ) analysis_test( name = name, + collect_actions_recursively = True, impl = _test_content_type_from_description_impl, target = name + "_subject", ) def _test_content_type_from_description_impl(env, target): - action = env.expect.that_target(target).action_generating( - "{package}/{name}.metadata.txt", + dist_info_target = _dist_info_target_subject( + env.expect.that_target(target), + ) + action = dist_info_target.action_generating( + "{package}/{name}.dist_info.metadata.txt", ) action.content().split("\n").contains( "Description-Content-Type: text/markdown", @@ -227,6 +246,7 @@ def _test_default_make_variables(name): ) analysis_test( name = name, + collect_actions_recursively = True, impl = _test_default_make_variables_impl, target = name + "_subject", ) @@ -247,8 +267,11 @@ def _test_default_make_variables_impl(env, target): "--platform", "linux_cpython_t", ]) - metadata_action = env.expect.that_target(target).action_generating( - "{package}/{name}.metadata.txt", + dist_info_target = _dist_info_target_subject( + env.expect.that_target(target), + ) + metadata_action = dist_info_target.action_generating( + "{package}/{name}.dist_info.metadata.txt", ) metadata_action.content().split("\n").contains( "Requires-Python: ==3.13.*", @@ -313,6 +336,78 @@ def _test_custom_make_variables_impl(env, target): _tests.append(_test_custom_make_variables) +def _test_explicit_dist_info(name): + rt_util.helper_target( + py_dist_info, + name = name + "_custom_dist_info", + distribution = "mydist_" + name, + summary = "Summary from explicit dist_info", + version = "2.3.4", + ) + rt_util.helper_target( + py_wheel, + name = name + "_subject", + dist_info = name + "_custom_dist_info", + ) + analysis_test( + name = name, + collect_actions_recursively = True, + impl = _test_explicit_dist_info_impl, + target = name + "_subject", + ) + +def _test_explicit_dist_info_impl(env, target): + target_subject = env.expect.that_target(target) + target_subject.default_outputs().contains_exactly([ + "{package}/mydist_{test_name}-2.3.4-py3-none-any.whl", + ]) + action = target_subject.action_named("PyWheel") + action.contains_at_least_args([ + "--name", + env.expect.meta.format_str("mydist_{test_name}"), + "--version", + "2.3.4", + ]) + dist_info_target = _dist_info_target_subject(target_subject) + metadata_action = dist_info_target.action_generating( + "{package}/{test_name}_custom_dist_info.metadata.txt", + ) + metadata_action.content().split("\n").contains( + "Summary: Summary from explicit dist_info", + ) + +_tests.append(_test_explicit_dist_info) + +def _test_dist_info_conflict(name): + rt_util.helper_target( + py_dist_info, + name = name + "_custom_dist_info", + distribution = "mydist_" + name, + version = "2.3.4", + ) + rt_util.helper_target( + py_wheel, + name = name + "_subject", + dist_info = name + "_custom_dist_info", + summary = "Conflicting summary", + version = "9.9.9", + ) + analysis_test( + name = name, + impl = _test_dist_info_conflict_impl, + target = name + "_subject", + expect_failure = True, + ) + +def _test_dist_info_conflict_impl(env, target): + env.expect.that_target(target).failures().contains_predicate( + matching.str_matches( + "Cannot specify `summary`, `version` together with `dist_info`", + ), + ) + +_tests.append(_test_dist_info_conflict) + def py_wheel_test_suite(name): test_suite( name = name,