From c729acb92b3a28b51f022619a514d662012f14cb Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 17 Aug 2026 01:05:15 +0000 Subject: [PATCH 1/2] agents(plans): add design plan for py_wheel custom metadata and PEP 639 Propose design for py_wheel to support PEP 639 license metadata, configurable metadata_fields, metadata_file merging, and generalized distinfo_files path transformations. --- .agents/plans/py_wheel_metadata_plan.md | 177 ++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 .agents/plans/py_wheel_metadata_plan.md diff --git a/.agents/plans/py_wheel_metadata_plan.md b/.agents/plans/py_wheel_metadata_plan.md new file mode 100644 index 0000000000..98fbd05efc --- /dev/null +++ b/.agents/plans/py_wheel_metadata_plan.md @@ -0,0 +1,177 @@ +# Implementation Plan - Support Custom Metadata & PEP 639 in `py_wheel` + +This plan details the design for issue +[#4042](https://github.com/bazel-contrib/rules_python/issues/4042) and generalized +distribution files: +1. `metadata_fields`: `attr.string_list_dict` (configurable, emitting one header + line per list item). +2. `metadata_file`: `attr.label(allow_single_file = True)` (RFC 822 metadata file + to merge into and take precedence over generated metadata). +3. `extra_distinfo_files`: `attr.label_keyed_string_dict` (enhanced with + `strip_prefix|prefix` path transformation, `.dist-info` auto-stripping, and + multi-file directory placement). +4. `license_expression`: `attr.string()` for {pep}`639`. + +--- + +## Multiple-Value Header Formatting + +In Core Metadata ({pep}`566` / RFC 822), multiple-use fields are represented as +repeated header lines with the same key. + +In `metadata_fields`, each item in a key's list is emitted as a distinct header +line: + +```python +metadata_fields = { + "License-Expression": ["Apache-2.0 AND MIT"], + "License-File": ["LICENSE", "third_party/dep.txt"], + "Classifier": [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + ], + "Dynamic": ["classifiers"], +} +``` + +Produces in `METADATA`: +```http +License-Expression: Apache-2.0 AND MIT +License-File: LICENSE +License-File: third_party/dep.txt +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Programming Language :: Python :: 3 +Dynamic: classifiers +``` + +--- + +## `extra_distinfo_files` Path Transformation Syntax + +`extra_distinfo_files` maps labels to destination paths inside `.dist-info/`. + +### 1. `strip_prefix|prefix` Syntax +To give users precise control over file paths inside `.dist-info/`, the value +supports a `strip_prefix|prefix` syntax: +- `strip_prefix` is removed from the beginning of each file's path. +- `prefix` is prepended to the remaining path under `.dist-info/`. + +Example: +```python +extra_distinfo_files = { + # Takes files from //some:licenses, removes "some/" prefix, and puts them + # in the "licenses" directory under .dist-info/ (e.g. some/pkg/LICENSE -> + # .dist-info/licenses/pkg/LICENSE) + "//some:licenses": "some/|licenses", +} +``` + +#### Special Case: Empty `strip_prefix` +If `strip_prefix` is empty (e.g. `"|licenses"` or `"|"`), `{distribution-name}*.dist-info` +is searched for in the file's path. If found, the entire path segment up to and +including `.dist-info/` is automatically computed and used as the strip prefix: +- E.g. A file path `"foo/bar/mydist.dist-info/licenses/data.txt"` with `"|"` will + have `"foo/bar/mydist.dist-info/"` stripped, placing `"licenses/data.txt"` + under `.dist-info/licenses/data.txt`. + +### 2. Standard Value Syntax (without `|`) +If `|` is not present in the value: +- **Single-file target**: If the target provides exactly 1 file, the value is + the exact relative destination file path under `.dist-info/` (e.g. + `"//:LICENSE": "licenses/LICENSE"`). +- **Multi-file target**: If the target provides multiple files, the value is + treated as a directory under `.dist-info/`, placing each file using its + basename (e.g. `":all_licenses": "licenses"` puts files under + `.dist-info/licenses/`). + +--- + +## Proposed Changes + +### Packaging API & Rule Implementation + +--- + +#### [MODIFY] [`python/packaging.bzl`](file:///usr/local/google/home/rlevasseur/.gemini/jetski/worktrees/rules_python/add_py_wheel_metadata/python/packaging.bzl) + +- Update `py_wheel` macro signature and docstrings to accept `metadata_fields`, + `metadata_file`, `license_expression`. +- Forward all attributes to `_py_wheel`. + +--- + +#### [MODIFY] [`python/private/py_wheel.bzl`](file:///usr/local/google/home/rlevasseur/.gemini/jetski/worktrees/rules_python/add_py_wheel_metadata/python/private/py_wheel.bzl) + +1. Add attributes to `py_wheel_lib.attrs`: + - `metadata_fields`: `attr.string_list_dict()` + - `metadata_file`: `attr.label(allow_single_file = True)` + - `license_expression`: `attr.string()` +2. In `_py_wheel_impl`: + - Support `strip_prefix|prefix` syntax (including auto-stripping + `{dist}*.dist-info/` when `strip_prefix` is empty) and multi-file directory + placement in `extra_distinfo_files`. + - Enforce `license` vs `license_expression` mutual exclusion. + - Format `metadata_fields` into repeated header lines. + - Elevate `Metadata-Version` to `2.4` if `license_expression` or + `License-Expression`/`License-File` is present in `metadata_fields`. + - Pass `--merge_metadata_file` to `wheelmaker.py` when `metadata_file` is + provided. + +--- + +#### [MODIFY] [`tools/wheelmaker.py`](file:///usr/local/google/home/rlevasseur/.gemini/jetski/worktrees/rules_python/add_py_wheel_metadata/tools/wheelmaker.py) + +1. Add `--merge_metadata_file` CLI argument. +2. Support `extra_distinfo_file` destinations with full relative path handling + and directory prefix resolution. +3. If `--merge_metadata_file` is provided, parse and merge RFC 822 headers and + body into `METADATA` (taking precedence over generated base headers for + single-use fields and appending for multi-use fields). + +--- + +### Tests + +--- + +#### [MODIFY] [`tests/py_wheel/py_wheel_tests.bzl`](file:///usr/local/google/home/rlevasseur/.gemini/jetski/worktrees/rules_python/add_py_wheel_metadata/tests/py_wheel/py_wheel_tests.bzl) + +- Analysis tests for: + - `extra_distinfo_files` with `strip_prefix|prefix` syntax (including empty + `strip_prefix` auto-stripping) and multi-file targets. + - Multi-line header generation from `metadata_fields`. + - `license_expression` mutual exclusion and `Metadata-Version: 2.4` elevation. + - `metadata_file` action input and `--merge_metadata_file` argument + propagation. + +--- + +#### [MODIFY] [`examples/wheel/BUILD.bazel`](file:///usr/local/google/home/rlevasseur/.gemini/jetski/worktrees/rules_python/add_py_wheel_metadata/examples/wheel/BUILD.bazel) and [`examples/wheel/wheel_test.py`](file:///usr/local/google/home/rlevasseur/.gemini/jetski/worktrees/rules_python/add_py_wheel_metadata/examples/wheel/wheel_test.py) + +- Integration tests verifying wheels containing: + - `extra_distinfo_files` with `strip_prefix|prefix` and auto-stripped + dist-info directories (licenses, SBOMs). + - Multiple `License-File` and `Classifier` lines via `metadata_fields`. + - Merged metadata from `metadata_file` taking precedence over base metadata. + +--- + +## Verification Plan + +### Automated Tests +1. Run analysis tests: + ```bash + bazel test --config=fast-tests //tests/py_wheel:... + ``` +2. Run wheel integration tests: + ```bash + bazel test --config=fast-tests //examples/wheel:wheel_test + ``` +3. Run all fast tests across the repository: + ```bash + bazel test --config=fast-tests //... + ``` +4. Verify documentation build: + ```bash + bazel build //docs:docs + ``` From b05b6ede2f83df9e51733e82249130ed80a81c47 Mon Sep 17 00:00:00 2001 From: Richard Levasseur Date: Mon, 17 Aug 2026 07:13:58 +0000 Subject: [PATCH 2/2] feat(py_wheel): add custom metadata fields and PEP 639 license support `py_wheel` lacked support for custom Core Metadata headers, external metadata merging, PEP 639 license expressions, and flexible .dist-info/ subpath mapping. Add `license_expression`, `metadata_fields`, and `metadata_file` attributes to `py_wheel`, update `wheelmaker.py` with RFC 822 merging and PEP 639 metadata handling, enhance `extra_distinfo_files` to support `strip_prefix|prefix` syntax, and add comprehensive tests. Closes #4042 --- examples/wheel/BUILD.bazel | 56 +++++++ examples/wheel/wheel_test.py | 65 ++++++++ news/4042.added.md | 4 + python/packaging.bzl | 46 +++--- python/private/py_wheel.bzl | 243 +++++++++++++++++++++++++----- tests/py_wheel/py_wheel_tests.bzl | 145 ++++++++++++++++++ tools/wheelmaker.py | 143 +++++++++++++++++- 7 files changed, 647 insertions(+), 55 deletions(-) create mode 100644 news/4042.added.md diff --git a/examples/wheel/BUILD.bazel b/examples/wheel/BUILD.bazel index 01dc4fab41..b691b3d3cc 100644 --- a/examples/wheel/BUILD.bazel +++ b/examples/wheel/BUILD.bazel @@ -443,6 +443,60 @@ py_wheel( version = "0.0.1", ) +py_wheel( + name = "pep639_wheel", + distribution = "example_pep639", + extra_distinfo_files = { + "//examples/wheel:NOTICE": "licenses/NOTICE", + ":data_files_test_group": "examples/wheel/|licenses/group", + }, + license_expression = "Apache-2.0 AND MIT", + metadata_fields = { + "Classifier": [ + "Topic :: Software Development :: Build Tools", + "License :: OSI Approved :: Apache Software License", + ], + "Dynamic": ["classifiers"], + "Keywords": [ + "bazel", + "pep639", + ], + "License-File": [ + "licenses/NOTICE", + "licenses/group/README.md", + ], + }, + python_tag = "py3", + version = "0.0.1", + deps = [":example_pkg"], +) + +write_file( + name = "merge_metadata_file", + out = "merge_metadata.txt", + content = [ + "Summary: Merged summary from file", + "License-Expression: MIT", + "Keywords: merged", + "License-File: LICENSES/MIT.txt", + "", + "This is the description body from merged metadata file.", + ], +) + +py_wheel( + name = "merged_metadata_wheel", + distribution = "example_merged_metadata", + extra_distinfo_files = { + "//examples/wheel:NOTICE": "LICENSES/MIT.txt", + }, + metadata_file = ":merge_metadata.txt", + python_tag = "py3", + summary = "Original summary", + version = "0.0.1", + deps = [":example_pkg"], +) + py_test( name = "wheel_test", srcs = ["wheel_test.py"], @@ -455,10 +509,12 @@ py_test( ":empty_requires_files", ":extra_requires", ":filename_escaping", + ":merged_metadata_wheel", ":minimal_data_files", ":minimal_with_py_library", ":minimal_with_py_library_with_stamp", ":minimal_with_py_package", + ":pep639_wheel", ":python_abi3_binary_wheel", ":python_requires_in_a_package", ":requires_dist_depends_on_extras", diff --git a/examples/wheel/wheel_test.py b/examples/wheel/wheel_test.py index d289cb7c8a..570eeb9784 100644 --- a/examples/wheel/wheel_test.py +++ b/examples/wheel/wheel_test.py @@ -639,6 +639,71 @@ def test_data_files_installed_in_folder(self): ], ) + def test_pep639_wheel(self): + filename = self._get_path("example_pep639-0.0.1-py3-none-any.whl") + + with zipfile.ZipFile(filename) as zf: + self.assertAllEntriesHasReproducibleMetadata(zf) + namelist = zf.namelist() + self.assertIn("example_pep639-0.0.1.dist-info/licenses/NOTICE", namelist) + self.assertIn( + "example_pep639-0.0.1.dist-info/licenses/group/NOTICE", namelist + ) + self.assertIn( + "example_pep639-0.0.1.dist-info/licenses/group/README.md", + namelist, + ) + + metadata = zf.read("example_pep639-0.0.1.dist-info/METADATA").decode( + "utf-8" + ) + lines = [line.strip() for line in metadata.splitlines()] + self.assertIn("Metadata-Version: 2.4", lines) + self.assertIn("Name: example_pep639", lines) + self.assertIn("Version: 0.0.1", lines) + self.assertIn("License-Expression: Apache-2.0 AND MIT", lines) + self.assertIn("License-File: licenses/NOTICE", lines) + self.assertIn("License-File: licenses/group/README.md", lines) + self.assertIn("Keywords: bazel, pep639", lines) + self.assertIn("Dynamic: classifiers", lines) + self.assertIn( + "Classifier: Topic :: Software Development :: Build Tools", + lines, + ) + self.assertIn( + "Classifier: License :: OSI Approved :: Apache Software License", + lines, + ) + + def test_merged_metadata_wheel(self): + filename = self._get_path("example_merged_metadata-0.0.1-py3-none-any.whl") + + with zipfile.ZipFile(filename) as zf: + self.assertAllEntriesHasReproducibleMetadata(zf) + namelist = zf.namelist() + self.assertIn( + "example_merged_metadata-0.0.1.dist-info/LICENSES/MIT.txt", + namelist, + ) + + metadata = zf.read( + "example_merged_metadata-0.0.1.dist-info/METADATA" + ).decode("utf-8") + lines = [line.strip() for line in metadata.splitlines()] + self.assertIn("Metadata-Version: 2.4", lines) + self.assertIn("Name: example_merged_metadata", lines) + self.assertIn("Version: 0.0.1", lines) + self.assertIn("Summary: Merged summary from file", lines) + self.assertNotIn("Summary: Original summary", lines) + self.assertIn("License-Expression: MIT", lines) + self.assertIn("Keywords: merged", lines) + self.assertIn("License-File: LICENSES/MIT.txt", lines) + self.assertTrue( + metadata.endswith( + "This is the description body from merged metadata file.\n" + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/news/4042.added.md b/news/4042.added.md new file mode 100644 index 0000000000..538ae69ce2 --- /dev/null +++ b/news/4042.added.md @@ -0,0 +1,4 @@ +(py_wheel) Added {obj}`license_expression` ({pep}`639`), {obj}`metadata_fields`, +and {obj}`metadata_file` attributes, and enhanced {obj}`extra_distinfo_files` +to support `strip_prefix|prefix` path transformations +([#4042](https://github.com/bazel-contrib/rules_python/issues/4042)). diff --git a/python/packaging.bzl b/python/packaging.bzl index fb333ca72f..4e4cfaa180 100644 --- a/python/packaging.bzl +++ b/python/packaging.bzl @@ -101,11 +101,6 @@ def py_wheel( Currently only pure-python wheels are supported. - :::{versionchanged} 1.4.0 - From now on, an empty `requires_file` is treated as if it were omitted, resulting in a valid - `METADATA` file. - ::: - Examples: ```python @@ -141,11 +136,11 @@ def py_wheel( ) ``` - To publish the wheel to PyPI, the twine package is required and it is installed - by default on `bzlmod` setups. On legacy `WORKSPACE`, `rules_python` - doesn't provide `twine` itself - (see {gh-issue}`1016`), but - you can install it with `pip_parse`, just like we do any other dependencies. + To publish the wheel to PyPI, the twine package is required and it is + installed by default on `bzlmod` setups. On legacy `WORKSPACE`, + `rules_python` doesn't provide `twine` itself (see {gh-issue}`1016`), but + you can install it with `pip_parse`, just like we do any other + dependencies. Once you've installed twine, you can pass its label to the `twine` attribute of this macro, to get a "[name].publish" target. @@ -160,7 +155,8 @@ def py_wheel( ) ``` - Now you can run a command like the following, which publishes to + Now you can run a command like the following, which publishes to + ```sh % TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-*** \\ @@ -168,14 +164,30 @@ def py_wheel( //path/to:my_wheel.publish --repository testpypi ``` + :::{versionchanged} 1.4.0 + From now on, an empty `requires_file` is treated as if it were omitted, + resulting in a valid `METADATA` file. + ::: + + :::{versionadded} VERSION_NEXT_FEATURE + Added `license_expression`, `metadata_fields`, and `metadata_file` + attributes, and enhanced `extra_distinfo_files` with `strip_prefix|prefix` + syntax. + ::: + Args: name: A unique name for this target. - twine: A label of the external location of the py_library target for twine - twine_binary: A label of the external location of a binary target for twine. - publish_args: arguments passed to twine, e.g. `["--repository-url", "https://pypi.my.org/simple/"]`. - These are subject to make var expansion, as with the `args` attribute. - Note that you can also pass additional args to the bazel run command as in the example above. - **kwargs: other named parameters passed to the underlying [py_wheel rule](#py_wheel_rule) + twine: A label of the external location of the py_library target for + twine. + twine_binary: A label of the external location of a binary target for + twine. + publish_args: arguments passed to twine, e.g. + `["--repository-url", "https://pypi.my.org/simple/"]`. + These are subject to make var expansion, as with the `args` + attribute. Note that you can also pass additional args to the bazel + run command as in the example above. + **kwargs: other named parameters passed to the underlying + [py_wheel rule](#py_wheel_rule) """ tags = kwargs.pop("tags", []) manual_tags = depset(tags + ["manual"]).to_list() diff --git a/python/private/py_wheel.bzl b/python/private/py_wheel.bzl index c28aefff48..e569f75336 100644 --- a/python/private/py_wheel.bzl +++ b/python/private/py_wheel.bzl @@ -240,7 +240,28 @@ be moved under that directory. allow_single_file = True, ), "extra_distinfo_files": attr.label_keyed_string_dict( - doc = "Extra files to add to distinfo directory in the archive.", + doc = """ +Extra files to add to distinfo directory in the archive. + +The keys are targets of files to include, and the values are the destination +paths relative to the `.dist-info` directory. + +The value supports a `strip_prefix|prefix` syntax: +- `strip_prefix`: removed from the beginning of the file's path. +- `prefix`: prepended to the remaining path under `.dist-info/`. +- If `strip_prefix` is empty (e.g. `"|"` or `"|licenses"`), any prefix up to + and including `{distribution}*.dist-info/` in the file path is automatically + stripped. + +If `|` is not present: +- For single-file targets: the value is the relative destination path. +- For multi-file targets or values ending in `/`: the value is treated as a + directory under `.dist-info/`, placing files using their basenames. + +:::{versionchanged} VERSION_NEXT_FEATURE +Added `strip_prefix|prefix` path transformation support. +::: +""", allow_files = True, ), "homepage": attr.string( @@ -251,6 +272,42 @@ be moved under that directory. doc = "A string specifying the license of the package.", default = "", ), + "license_expression": attr.string( + doc = """ +An SPDX license expression per PEP 639 (e.g. 'Apache-2.0 AND MIT'). +Mutually exclusive with `license`. + +:::{versionadded} VERSION_NEXT_FEATURE +The `license_expression` attribute was added. +::: +""", + default = "", + ), + "metadata_fields": attr.string_list_dict( + doc = """ +A mapping of metadata field names to list of values. + +Values are emitted as `Key: value` lines in `METADATA`. Multi-value fields +(such as `License-File`, `Classifier`, `Dynamic`) emit repeated lines. +Single-use fields override default generated values. + +:::{versionadded} VERSION_NEXT_FEATURE +The `metadata_fields` attribute was added. +::: +""", + ), + "metadata_file": attr.label( + doc = """ +An RFC 822 formatted metadata file whose contents are merged into the generated +METADATA file. Single-use headers in this file take precedence over default +generated headers. + +:::{versionadded} VERSION_NEXT_FEATURE +The `metadata_file` attribute was added. +::: +""", + allow_single_file = True, + ), "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. " + @@ -286,6 +343,52 @@ _DESCRIPTION_FILE_EXTENSION_TO_TYPE = { } _DEFAULT_DESCRIPTION_FILE_TYPE = "text/plain" +_SINGLE_USE_METADATA_FIELDS = { + "author": True, + "author-email": True, + "description-content-type": True, + "download-url": True, + "home-page": True, + "keywords": True, + "license": True, + "license-expression": True, + "maintainer": True, + "maintainer-email": True, + "metadata-version": True, + "name": True, + "requires-python": True, + "summary": True, + "version": True, +} + +def _calculate_distinfo_dest(file, spec, num_files): + """Calculates destination path inside .dist-info/ for extra distinfo. + """ + if "|" in spec: + strip_prefix, _, prefix = spec.partition("|") + file_path = py_package_lib.path_inside_wheel(file) + if strip_prefix: + if file_path.startswith(strip_prefix): + rel_path = file_path[len(strip_prefix):] + else: + rel_path = file_path + else: + # Special case: empty strip_prefix looks for .dist-info/ in + # file_path + pos = file_path.find(".dist-info/") + if pos != -1: + rel_path = file_path[pos + len(".dist-info/"):] + else: + rel_path = file.basename + if prefix and not prefix.endswith("/"): + prefix = prefix + "/" + return (prefix + rel_path).lstrip("/") + elif num_files > 1 or spec.endswith("/"): + dir_prefix = spec if spec.endswith("/") else spec + "/" + return (dir_prefix + file.basename).lstrip("/") + else: + return spec + def _escape_filename_distribution_name(name): """Escape the distribution name component of a filename. @@ -418,61 +521,98 @@ def _py_wheel_impl(ctx): # 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.license and ctx.attr.license_expression: + fail( + "`license` and `license_expression` are mutually exclusive on {}".format( + ctx.label, + ), + ) + + metadata_version = "2.1" + if ctx.attr.license_expression: + metadata_version = "2.4" + for k in ctx.attr.metadata_fields.keys(): + kl = k.lower() + if kl in ("license-expression", "license-file"): + metadata_version = "2.4" + for k, vals in ctx.attr.metadata_fields.items(): + if k.lower() == "metadata-version" and vals: + metadata_version = vals[0] + + single_use = {} + multi_use = [] + + single_use["metadata-version"] = ("Metadata-Version", metadata_version) + single_use["name"] = ("Name", ctx.attr.distribution) if ctx.attr.author: - metadata_contents.append("Author: %s" % ctx.attr.author) + single_use["author"] = ("Author", ctx.attr.author) if ctx.attr.author_email: - metadata_contents.append("Author-email: %s" % ctx.attr.author_email) + single_use["author-email"] = ("Author-email", ctx.attr.author_email) if ctx.attr.homepage: - metadata_contents.append("Home-page: %s" % ctx.attr.homepage) + single_use["home-page"] = ("Home-page", ctx.attr.homepage) if ctx.attr.license: - metadata_contents.append("License: %s" % ctx.attr.license) + single_use["license"] = ("License", ctx.attr.license) + if ctx.attr.license_expression: + single_use["license-expression"] = ( + "License-Expression", + ctx.attr.license_expression, + ) if ctx.attr.description_content_type: - metadata_contents.append("Description-Content-Type: %s" % ctx.attr.description_content_type) + single_use["description-content-type"] = ( + "Description-Content-Type", + 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) + single_use["description-content-type"] = ( + "Description-Content-Type", + description_file_type, + ) if ctx.attr.summary: - metadata_contents.append("Summary: %s" % ctx.attr.summary) + single_use["summary"] = ("Summary", 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)) + fail( + "`label` {} in `project_urls` is too long. It is limited to {} characters.".format( + len(label), + _PROJECT_URL_LABEL_LENGTH_LIMIT, + ), + ) + multi_use.append(("Project-URL", "%s, %s" % (label, url))) for c in ctx.attr.classifiers: - metadata_contents.append("Classifier: %s" % c) + multi_use.append(("Classifier", c)) if ctx.attr.python_requires: - metadata_contents.append("Requires-Python: %s" % ctx.attr.python_requires) + single_use["requires-python"] = ("Requires-Python", ctx.attr.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) + multi_use.append(("Requires-Dist", 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) + multi_use.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) + multi_use.append(("Provides-Extra", option)) for requirement in option_requirements: - metadata_contents.append( - "Requires-Dist: %s; extra == '%s'" % (requirement, option), - ) + multi_use.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: @@ -487,22 +627,55 @@ def _py_wheel_impl(ctx): 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( + multi_use.append(("Provides-Extra", option)) + multi_use.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), - ) + "Requires-Dist", + "@%s; extra == '%s'" % (option_requires_file.path, option), + )) other_inputs.append(option_requires_file) + # Process metadata_fields + for key, values in ctx.attr.metadata_fields.items(): + kl = key.lower() + if kl in _SINGLE_USE_METADATA_FIELDS: + if values: + val = ", ".join(values) if kl == "keywords" else values[0] + single_use[kl] = (key, val) + if kl == "license-expression": + single_use.pop("license", None) + else: + for val in values: + multi_use.append((key, val)) + + metadata_lines = [] + if "metadata-version" in single_use: + k, v = single_use.pop("metadata-version") + metadata_lines.append("%s: %s" % (k, v)) + if "name" in single_use: + k, v = single_use.pop("name") + metadata_lines.append("%s: %s" % (k, v)) + + for kl, (k, v) in single_use.items(): + metadata_lines.append("%s: %s" % (k, v)) + + for k, v in multi_use: + metadata_lines.append("%s: %s" % (k, v)) + + metadata_file = ctx.actions.declare_file(ctx.attr.name + ".metadata.txt") ctx.actions.write( output = metadata_file, - content = "\n".join(metadata_contents) + "\n", + content = "\n".join(metadata_lines) + "\n", ) other_inputs.append(metadata_file) args.add("--metadata_file", metadata_file) + if ctx.file.metadata_file: + other_inputs.append(ctx.file.metadata_file) + args.add("--merge_metadata_file", ctx.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: @@ -536,18 +709,16 @@ def _py_wheel_impl(ctx): if not ctx.attr.compress: args.add("--no_compress") - for target, filename in ctx.attr.extra_distinfo_files.items(): + for target, spec 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, + num_files = len(target_files) + for f in target_files: + other_inputs.append(f) + dest = _calculate_distinfo_dest(f, spec, num_files) + args.add( + "--extra_distinfo_file", + dest + ";" + f.path, ) - other_inputs.extend(target_files) - args.add( - "--extra_distinfo_file", - filename + ";" + target_files[0].path, - ) for target, filename in ctx.attr.data_files.items(): target_files = target[DefaultInfo].files.to_list() diff --git a/tests/py_wheel/py_wheel_tests.bzl b/tests/py_wheel/py_wheel_tests.bzl index 75fef3a622..efd941e23e 100644 --- a/tests/py_wheel/py_wheel_tests.bzl +++ b/tests/py_wheel/py_wheel_tests.bzl @@ -206,6 +206,151 @@ def _test_config_settings_impl(env, target): _tests.append(_test_config_settings) +def _test_license_expression(name): + rt_util.helper_target( + py_wheel, + name = name + "_subject", + distribution = "mydist_" + name, + version = "0.0.0", + license_expression = "Apache-2.0 AND MIT", + ) + analysis_test( + name = name, + impl = _test_license_expression_impl, + target = name + "_subject", + ) + +def _test_license_expression_impl(env, target): + action = env.expect.that_target(target).action_generating( + "{package}/{name}.metadata.txt", + ) + action.content().split("\n").contains_at_least([ + "Metadata-Version: 2.4", + "License-Expression: Apache-2.0 AND MIT", + ]) + +_tests.append(_test_license_expression) + +def _test_license_expression_mutual_exclusion(name): + rt_util.helper_target( + py_wheel, + name = name + "_subject", + distribution = "mydist_" + name, + version = "0.0.0", + license = "Apache-2.0", + license_expression = "Apache-2.0", + ) + analysis_test( + name = name, + impl = _test_license_expression_mutual_exclusion_impl, + target = name + "_subject", + expect_failure = True, + ) + +def _test_license_expression_mutual_exclusion_impl(env, target): + env.expect.that_target(target).failures().contains_predicate( + matching.str_matches( + "`license` and `license_expression` are mutually exclusive", + ), + ) + +_tests.append(_test_license_expression_mutual_exclusion) + +def _test_metadata_fields(name): + rt_util.helper_target( + py_wheel, + name = name + "_subject", + distribution = "mydist_" + name, + version = "0.0.0", + summary = "old summary", + metadata_fields = { + "Dynamic": ["classifiers"], + "Keywords": ["bazel", "wheel"], + "License-File": ["LICENSE", "NOTICE"], + "Summary": ["new summary"], + }, + ) + analysis_test( + name = name, + impl = _test_metadata_fields_impl, + target = name + "_subject", + ) + +def _test_metadata_fields_impl(env, target): + action = env.expect.that_target(target).action_generating( + "{package}/{name}.metadata.txt", + ) + action.content().split("\n").contains_at_least([ + "Metadata-Version: 2.4", + "Summary: new summary", + "Keywords: bazel, wheel", + "License-File: LICENSE", + "License-File: NOTICE", + "Dynamic: classifiers", + ]) + action.content().split("\n").not_contains("Summary: old summary") + +_tests.append(_test_metadata_fields) + +def _test_metadata_file(name): + rt_util.helper_target( + py_wheel, + name = name + "_subject", + distribution = "mydist_" + name, + version = "0.0.0", + metadata_file = "desc.md", + ) + analysis_test( + name = name, + impl = _test_metadata_file_impl, + target = name + "_subject", + ) + +def _test_metadata_file_impl(env, target): + action = env.expect.that_target(target).action_named("PyWheel") + action.contains_at_least_args([ + "--merge_metadata_file", + "tests/py_wheel/desc.md", + ]) + action.contains_at_least_inputs(["tests/py_wheel/desc.md"]) + +_tests.append(_test_metadata_file) + +def _test_extra_distinfo_files_strip_prefix(name): + rt_util.helper_target( + native.filegroup, + name = name + "_files", + srcs = ["desc.md", "source_name"], + ) + rt_util.helper_target( + py_wheel, + name = name + "_subject", + distribution = "mydist_" + name, + version = "0.0.0", + extra_distinfo_files = { + ":" + name + "_files": "tests/py_wheel/|licenses", + "source_name": "licenses/NOTICE", + }, + ) + analysis_test( + name = name, + impl = _test_extra_distinfo_files_strip_prefix_impl, + target = name + "_subject", + ) + +def _test_extra_distinfo_files_strip_prefix_impl(env, target): + action = env.expect.that_target(target).action_named("PyWheel") + action.contains_at_least_args([ + "--extra_distinfo_file", + "licenses/desc.md;tests/py_wheel/desc.md", + "--extra_distinfo_file", + "licenses/source_name;tests/py_wheel/source_name", + "--extra_distinfo_file", + "licenses/NOTICE;tests/py_wheel/source_name", + ]) + +_tests.append(_test_extra_distinfo_files_strip_prefix) + def py_wheel_test_suite(name): test_suite( name = name, diff --git a/tools/wheelmaker.py b/tools/wheelmaker.py index 483e8fcefe..20e659ae40 100644 --- a/tools/wheelmaker.py +++ b/tools/wheelmaker.py @@ -357,8 +357,17 @@ def add_metadata(self, metadata, name, description): """Write METADATA file to the distribution.""" # https://www.python.org/dev/peps/pep-0566/ # https://packaging.python.org/specifications/core-metadata/ - metadata = re.sub("^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE) - metadata += "Version: %s\n\n" % self._version + metadata = metadata.rstrip() + if re.search(r"^Name: .*$", metadata, flags=re.MULTILINE): + metadata = re.sub( + r"^Name: .*$", "Name: %s" % name, metadata, flags=re.MULTILINE + ) + else: + metadata += "\nName: %s" % name + metadata = re.sub( + r"^Version: .*$\r?\n?", "", metadata, flags=re.MULTILINE + ).rstrip() + metadata += "\nVersion: %s\n\n" % self._version # setuptools seems to insert UNKNOWN as description when none is # provided. metadata += description if description else "UNKNOWN" @@ -501,6 +510,11 @@ def parse_args() -> argparse.Namespace: help="Contents of the METADATA file (before appending contents of " "--description_file)", ) + wheel_group.add_argument( + "--merge_metadata_file", + type=Path, + help="Path to an RFC 822 metadata file to merge into METADATA", + ) wheel_group.add_argument( "--description_file", help="Path to the file with package description" ) @@ -555,6 +569,124 @@ def parse_args() -> argparse.Namespace: return parser.parse_args(sys.argv[1:]) +SINGLE_USE_METADATA_FIELDS = { + "author", + "author-email", + "description-content-type", + "download-url", + "home-page", + "keywords", + "license", + "license-expression", + "maintainer", + "maintainer-email", + "metadata-version", + "name", + "requires-python", + "summary", + "version", +} + + +def parse_rfc822_metadata( + text: str, +) -> tuple[list[tuple[str, str]], str | None]: + """Parses RFC 822 format metadata into a list of (key, value) pairs + and optional description body. + """ + parts = re.split(r"\r?\n\r?\n", text, maxsplit=1) + header_text = parts[0] + body = parts[1] if len(parts) > 1 and parts[1].strip() else None + + headers: list[tuple[str, str]] = [] + current_key = None + current_val: list[str] = [] + + for line in header_text.splitlines(): + if not line: + continue + if line[0] in " \t" and current_key is not None: + current_val.append(line.lstrip()) + else: + if current_key is not None: + headers.append((current_key, " ".join(current_val))) + key, sep, val = line.partition(":") + if sep: + current_key = key.strip() + current_val = [val.strip()] + else: + current_key = None + current_val = [] + + if current_key is not None: + headers.append((current_key, " ".join(current_val))) + + return headers, body + + +def merge_metadata( + base_metadata_text: str, merge_file_path: Path +) -> tuple[str, str | None]: + """Merges an external metadata file into the base metadata text. + + Single-use fields from merge_file_path override base fields. + Multi-use fields from merge_file_path append to base fields. + If merge_file contains License-Expression, any existing License header + is removed. + Returns (merged_headers_text, description_body). + """ + merge_text = merge_file_path.read_text(encoding="utf-8") + merge_headers, merge_body = parse_rfc822_metadata(merge_text) + base_headers, _ = parse_rfc822_metadata(base_metadata_text) + + merge_single_use = {} + merge_has_license_expression = False + merge_has_pep639 = False + for k, v in merge_headers: + kl = k.lower() + if kl == "license-expression": + merge_has_license_expression = True + if kl in ("license-expression", "license-file"): + merge_has_pep639 = True + if kl in SINGLE_USE_METADATA_FIELDS: + merge_single_use[kl] = (k, v) + + if merge_has_pep639 and "metadata-version" not in merge_single_use: + merge_single_use["metadata-version"] = ("Metadata-Version", "2.4") + + result_headers: list[tuple[str, str]] = [] + seen_single_use = set() + + for k, v in base_headers: + kl = k.lower() + if kl == "license" and merge_has_license_expression: + continue + if kl in SINGLE_USE_METADATA_FIELDS: + if kl in merge_single_use: + if kl not in seen_single_use: + result_headers.append(merge_single_use[kl]) + seen_single_use.add(kl) + else: + if kl not in seen_single_use: + result_headers.append((k, v)) + seen_single_use.add(kl) + else: + result_headers.append((k, v)) + + for kl, (k, v) in merge_single_use.items(): + if kl not in seen_single_use: + result_headers.append((k, v)) + seen_single_use.add(kl) + + for k, v in merge_headers: + kl = k.lower() + if kl not in SINGLE_USE_METADATA_FIELDS: + result_headers.append((k, v)) + + merged_text = "\n".join(f"{k}: {v}" for k, v in result_headers) + "\n" + return merged_text, merge_body + + def _parse_file_pairs(content: list[str]) -> list[list[str]]: """ Parse ; delimited lists of files into a 2D list. @@ -624,6 +756,13 @@ def main() -> None: metadata = arguments.metadata_file.read_text(encoding="utf-8") + if arguments.merge_metadata_file: + metadata, merge_body = merge_metadata( + metadata, arguments.merge_metadata_file + ) + if merge_body and not description: + description = merge_body + # Search for any `Requires-Dist` entries that refer to other files and # expand them.