Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions .agents/plans/py_wheel_metadata_plan.md
Original file line number Diff line number Diff line change
@@ -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/<basename>`).

---

## 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
```
56 changes: 56 additions & 0 deletions examples/wheel/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions examples/wheel/wheel_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 4 additions & 0 deletions news/4042.added.md
Original file line number Diff line number Diff line change
@@ -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)).
Loading
Loading