Skip to content

Commit f4b8e48

Browse files
authored
Several fixes to WHEEL metadata handling (#588)
* cli: Add missing return type annotations * bdist_wheel: Remove dead code for postprocessing WHEEL line endings The default policies of Message and BytesGenerator always produce LF line endings, so there's never a CRLF to match. If there were, the existing code would have incorrectly replaced CRLF with CR, and then test_wheelfile_line_endings() would have failed. * cli: Fix removing build tag with `wheel pack --build-number ""` There's an existing test for this case, which verifies that the original build tag is removed from the wheel filename and is *not* removed from the WHEEL metadata. Presumably this is not what was intended. BUILD_NUM_RE assumed that it would only match at end of file, but there's at least a newline in the way. Fix it, and the test. * cli: Allow removing build tag with `wheel tags --build ""` For symmetry with `wheel pack`. The functionality works already; we just need to fix the command-line validation. * test: Restructure test_pack() around email.message.Message We're about to change pack() to emit WHEEL metadata with LF-terminated lines and a trailing blank line. In some test_pack() parameterizations we expect to see the original WHEEL file from the test fixture, and in some, a modified one. Handle the difference by comparing WHEEL contents parsed with email.parser, instead of byte strings. * cli: Remove hand-rolled WHEEL parsing/mutation code The PyPA Binary Distribution Format spec says the WHEEL metadata file is "in the same basic key: value format" as the METADATA file. METADATA in turn is governed by the Core Metadata Specifications, which say: "In the absence of a precise definition, the practical standard is set by what the standard library email.parser module can parse using the compat32 policy." wheel.bdist_wheel accordingly uses email.generator.BytesGenerator to generate WHEEL, but the CLI `pack` and `tags` commands opt to hand-implement WHEEL parsing and mutation. Their mutation functions, set_tags() and set_build_number(), append any new headers to the existing WHEEL file. Since WHEEL tends to have a trailing blank line (separating the headers from the nonexistent body), the new headers end up in the "body" and are ignored by any tool that parses WHEEL with email.parser. In addition, both functions assume that WHEEL uses CRLF line endings, while bdist_wheel (and email.generator with the compat32 policy) specifically always writes LF line endings. This turns out okay for set_tags(), which rewrites the whole file, but set_build_number() ends up appending a CRLF-terminated line to a file with LF-terminated lines. Fix all this by removing the hand-written parsing/mutation functions in favor of email.parser and email.generator.
1 parent 11e5732 commit f4b8e48

File tree

8 files changed

+63
-102
lines changed

8 files changed

+63
-102
lines changed

docs/news.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
Release Notes
22
=============
33

4+
**UNRELEASED**
5+
6+
- Fixed ``wheel pack`` and ``wheel tags`` writing updated ``WHEEL`` fields after a
7+
blank line, causing other tools to ignore them
8+
- Fixed ``wheel pack`` and ``wheel tags`` writing ``WHEEL`` with CRLF line endings or
9+
a mix of CRLF and LF
10+
- Allow removing build tag with ``wheel tags --build ""``
11+
- Fixed ``wheel pack --build-number ""`` not removing build tag from ``WHEEL``
12+
(above changes by Benjamin Gilbert)
13+
414
**0.41.3 (2023-10-30)**
515

616
- Updated vendored ``packaging`` to 23.2

src/wheel/bdist_wheel.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from email.generator import BytesGenerator, Generator
1818
from email.policy import EmailPolicy
1919
from glob import iglob
20-
from io import BytesIO
2120
from shutil import rmtree
2221
from zipfile import ZIP_DEFLATED, ZIP_STORED
2322

@@ -468,10 +467,8 @@ def write_wheelfile(
468467

469468
wheelfile_path = os.path.join(wheelfile_base, "WHEEL")
470469
log.info(f"creating {wheelfile_path}")
471-
buffer = BytesIO()
472-
BytesGenerator(buffer, maxheaderlen=0).flatten(msg)
473470
with open(wheelfile_path, "wb") as f:
474-
f.write(buffer.getvalue().replace(b"\r\n", b"\r"))
471+
BytesGenerator(f, maxheaderlen=0).flatten(msg)
475472

476473
def _ensure_relative(self, path):
477474
# copied from dir_util, deleted

src/wheel/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def version_f(args):
5858

5959

6060
def parse_build_tag(build_tag: str) -> str:
61-
if not build_tag[0].isdigit():
61+
if build_tag and not build_tag[0].isdigit():
6262
raise ArgumentTypeError("build tag must begin with a digit")
6363
elif "-" in build_tag:
6464
raise ArgumentTypeError("invalid character ('-') in build tag")

src/wheel/cli/convert.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def get_tag(self):
4242
return bdist_wheel.get_tag(self)
4343

4444

45-
def egg2wheel(egg_path: str, dest_dir: str):
45+
def egg2wheel(egg_path: str, dest_dir: str) -> None:
4646
filename = os.path.basename(egg_path)
4747
match = egg_info_re.match(filename)
4848
if not match:

src/wheel/cli/pack.py

Lines changed: 11 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from __future__ import annotations
22

3+
import email.policy
34
import os.path
45
import re
6+
from email.generator import BytesGenerator
7+
from email.parser import BytesParser
58

69
from wheel.cli import WheelError
710
from wheel.wheelfile import WheelFile
811

912
DIST_INFO_RE = re.compile(r"^(?P<namever>(?P<name>.+?)-(?P<ver>\d.*?))\.dist-info$")
10-
BUILD_NUM_RE = re.compile(rb"Build: (\d\w*)$")
1113

1214

13-
def pack(directory: str, dest_dir: str, build_number: str | None):
15+
def pack(directory: str, dest_dir: str, build_number: str | None) -> None:
1416
"""Repack a previously unpacked wheel directory into a new wheel file.
1517
1618
The .dist-info/WHEEL file must contain one or more tags so that the target
@@ -35,10 +37,11 @@ def pack(directory: str, dest_dir: str, build_number: str | None):
3537
name_version = DIST_INFO_RE.match(dist_info_dir).group("namever")
3638

3739
# Read the tags and the existing build number from .dist-info/WHEEL
38-
existing_build_number = None
3940
wheel_file_path = os.path.join(directory, dist_info_dir, "WHEEL")
4041
with open(wheel_file_path, "rb") as f:
41-
tags, existing_build_number = read_tags(f.read())
42+
info = BytesParser(policy=email.policy.compat32).parse(f)
43+
tags: list[str] = info.get_all("Tag", [])
44+
existing_build_number = info.get("Build")
4245

4346
if not tags:
4447
raise WheelError(
@@ -49,17 +52,14 @@ def pack(directory: str, dest_dir: str, build_number: str | None):
4952
# Set the wheel file name and add/replace/remove the Build tag in .dist-info/WHEEL
5053
build_number = build_number if build_number is not None else existing_build_number
5154
if build_number is not None:
55+
del info["Build"]
5256
if build_number:
57+
info["Build"] = build_number
5358
name_version += "-" + build_number
5459

5560
if build_number != existing_build_number:
56-
with open(wheel_file_path, "rb+") as f:
57-
wheel_file_content = f.read()
58-
wheel_file_content = set_build_number(wheel_file_content, build_number)
59-
60-
f.seek(0)
61-
f.truncate()
62-
f.write(wheel_file_content)
61+
with open(wheel_file_path, "wb") as f:
62+
BytesGenerator(f, maxheaderlen=0).flatten(info)
6363

6464
# Reassemble the tags for the wheel file
6565
tagline = compute_tagline(tags)
@@ -73,45 +73,6 @@ def pack(directory: str, dest_dir: str, build_number: str | None):
7373
print("OK")
7474

7575

76-
def read_tags(input_str: bytes) -> tuple[list[str], str | None]:
77-
"""Read tags from a string.
78-
79-
:param input_str: A string containing one or more tags, separated by spaces
80-
:return: A list of tags and a list of build tags
81-
"""
82-
83-
tags = []
84-
existing_build_number = None
85-
for line in input_str.splitlines():
86-
if line.startswith(b"Tag: "):
87-
tags.append(line.split(b" ")[1].rstrip().decode("ascii"))
88-
elif line.startswith(b"Build: "):
89-
existing_build_number = line.split(b" ")[1].rstrip().decode("ascii")
90-
91-
return tags, existing_build_number
92-
93-
94-
def set_build_number(wheel_file_content: bytes, build_number: str | None) -> bytes:
95-
"""Compute a build tag and add/replace/remove as necessary.
96-
97-
:param wheel_file_content: The contents of .dist-info/WHEEL
98-
:param build_number: The build tags present in .dist-info/WHEEL
99-
:return: The (modified) contents of .dist-info/WHEEL
100-
"""
101-
replacement = (
102-
("Build: %s\r\n" % build_number).encode("ascii") if build_number else b""
103-
)
104-
105-
wheel_file_content, num_replaced = BUILD_NUM_RE.subn(
106-
replacement, wheel_file_content
107-
)
108-
109-
if not num_replaced:
110-
wheel_file_content += replacement
111-
112-
return wheel_file_content
113-
114-
11576
def compute_tagline(tags: list[str]) -> str:
11677
"""Compute a tagline from a list of tags.
11778

src/wheel/cli/tags.py

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from __future__ import annotations
22

3+
import email.policy
34
import itertools
45
import os
56
from collections.abc import Iterable
7+
from email.parser import BytesParser
68

79
from ..wheelfile import WheelFile
8-
from .pack import read_tags, set_build_number
910

1011

1112
def _compute_tags(original_tags: Iterable[str], new_tags: str | None) -> set[str]:
@@ -48,6 +49,7 @@ def tags(
4849
assert f.filename, f"{f.filename} must be available"
4950

5051
wheel_info = f.read(f.dist_info_path + "/WHEEL")
52+
info = BytesParser(policy=email.policy.compat32).parsebytes(wheel_info)
5153

5254
original_wheel_name = os.path.basename(f.filename)
5355
namever = f.parsed_filename.group("namever")
@@ -56,7 +58,8 @@ def tags(
5658
original_abi_tags = f.parsed_filename.group("abi").split(".")
5759
original_plat_tags = f.parsed_filename.group("plat").split(".")
5860

59-
tags, existing_build_tag = read_tags(wheel_info)
61+
tags: list[str] = info.get_all("Tag", [])
62+
existing_build_tag = info.get("Build")
6063

6164
impls = {tag.split("-")[0] for tag in tags}
6265
abivers = {tag.split("-")[1] for tag in tags}
@@ -103,12 +106,13 @@ def tags(
103106
final_wheel_name = "-".join(final_tags) + ".whl"
104107

105108
if original_wheel_name != final_wheel_name:
106-
tags = [
107-
f"{a}-{b}-{c}"
108-
for a, b, c in itertools.product(
109-
final_python_tags, final_abi_tags, final_plat_tags
110-
)
111-
]
109+
del info["Tag"], info["Build"]
110+
for a, b, c in itertools.product(
111+
final_python_tags, final_abi_tags, final_plat_tags
112+
):
113+
info["Tag"] = f"{a}-{b}-{c}"
114+
if build:
115+
info["Build"] = build
112116

113117
original_wheel_path = os.path.join(
114118
os.path.dirname(f.filename), original_wheel_name
@@ -125,29 +129,11 @@ def tags(
125129
if item.filename == f.dist_info_path + "/RECORD":
126130
continue
127131
if item.filename == f.dist_info_path + "/WHEEL":
128-
content = fin.read(item)
129-
content = set_tags(content, tags)
130-
content = set_build_number(content, build)
131-
fout.writestr(item, content)
132+
fout.writestr(item, info.as_bytes())
132133
else:
133134
fout.writestr(item, fin.read(item))
134135

135136
if remove:
136137
os.remove(original_wheel_path)
137138

138139
return final_wheel_name
139-
140-
141-
def set_tags(in_string: bytes, tags: Iterable[str]) -> bytes:
142-
"""Set the tags in the .dist-info/WHEEL file contents.
143-
144-
:param in_string: The string to modify.
145-
:param tags: The tags to set.
146-
"""
147-
148-
lines = [line for line in in_string.splitlines() if not line.startswith(b"Tag:")]
149-
for tag in tags:
150-
lines.append(b"Tag: " + tag.encode("ascii"))
151-
in_string = b"\r\n".join(lines) + b"\r\n"
152-
153-
return in_string

tests/cli/test_pack.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import email.policy
34
import os
4-
from textwrap import dedent
5+
from email.message import Message
6+
from email.parser import BytesParser
57
from zipfile import ZipFile
68

79
import pytest
@@ -55,22 +57,25 @@ def test_pack(tmp_path_factory, tmp_path, build_tag_arg, existing_build_tag, fil
5557
if line and not line.startswith(b"test-1.0.dist-info/WHEEL,")
5658
)
5759

58-
new_wheel_file_content = zf.read("test-1.0.dist-info/WHEEL")
60+
parser = BytesParser(policy=email.policy.compat32)
61+
new_wheel_file_content = parser.parsebytes(zf.read("test-1.0.dist-info/WHEEL"))
5962

6063
assert new_record_lines == old_record_lines
6164

62-
expected_build_num = build_tag_arg or existing_build_tag
63-
expected_wheel_content = dedent(
64-
"""\
65-
Wheel-Version: 1.0
66-
Generator: bdist_wheel (0.30.0)
67-
Root-Is-Purelib: false
68-
Tag: py2-none-any
69-
Tag: py3-none-any
70-
""".replace("\n", "\r\n")
65+
# Line endings and trailing blank line will depend on whether WHEEL
66+
# was modified. Circumvent this by comparing parsed key/value pairs.
67+
expected_wheel_content = Message()
68+
expected_wheel_content["Wheel-Version"] = "1.0"
69+
expected_wheel_content["Generator"] = "bdist_wheel (0.30.0)"
70+
expected_wheel_content["Root-Is-Purelib"] = "false"
71+
expected_wheel_content["Tag"] = "py2-none-any"
72+
expected_wheel_content["Tag"] = "py3-none-any"
73+
expected_build_num = (
74+
build_tag_arg if build_tag_arg is not None else existing_build_tag
7175
)
7276
if expected_build_num:
73-
expected_wheel_content += "Build: %s\r\n" % expected_build_num
77+
expected_wheel_content["Build"] = expected_build_num
7478

75-
expected_wheel_content = expected_wheel_content.encode("ascii")
76-
assert new_wheel_file_content == expected_wheel_content
79+
assert sorted(new_wheel_file_content.items()) == sorted(
80+
expected_wheel_content.items()
81+
)

tests/cli/test_tags.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ def test_python_tags(wheelpath):
3939
with WheelFile(str(output_file)) as f:
4040
output = f.read(f.dist_info_path + "/WHEEL")
4141
assert (
42-
output == b"Wheel-Version: 1.0\r\nGenerator: bdist_wheel (0.30.0)"
43-
b"\r\nRoot-Is-Purelib: false\r\nTag: py3-none-any\r\n"
42+
output == b"Wheel-Version: 1.0\nGenerator: bdist_wheel (0.30.0)"
43+
b"\nRoot-Is-Purelib: false\nTag: py3-none-any\n\n"
4444
)
4545
output_file.unlink()
4646

@@ -116,6 +116,8 @@ def test_build_tag(wheelpath):
116116
assert TESTWHEEL_NAME.replace("-py2", "-1bah-py2") == newname
117117
output_file = wheelpath.parent / newname
118118
assert output_file.exists()
119+
newname = tags(str(wheelpath), build_tag="")
120+
assert TESTWHEEL_NAME == newname
119121
output_file.unlink()
120122

121123

@@ -151,9 +153,9 @@ def test_multi_tags(wheelpath):
151153
output = f.read(f.dist_info_path + "/WHEEL")
152154
assert (
153155
output
154-
== b"Wheel-Version: 1.0\r\nGenerator: bdist_wheel (0.30.0)\r\nRoot-Is-Purelib:"
155-
b" false\r\nTag: py2-none-linux_x86_64\r\nTag: py3-none-linux_x86_64\r\nTag:"
156-
b" py4-none-linux_x86_64\r\nBuild: 1\r\n"
156+
== b"Wheel-Version: 1.0\nGenerator: bdist_wheel (0.30.0)\nRoot-Is-Purelib:"
157+
b" false\nTag: py2-none-linux_x86_64\nTag: py3-none-linux_x86_64\nTag:"
158+
b" py4-none-linux_x86_64\nBuild: 1\n\n"
157159
)
158160
output_file.unlink()
159161

0 commit comments

Comments
 (0)