Skip to content

Commit c099007

Browse files
authored
Merge branch 'main' into henryiii/tests/warning
2 parents d6ff81d + e0f18dd commit c099007

File tree

6 files changed

+531
-20
lines changed

6 files changed

+531
-20
lines changed

docs/reference/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ Reference Guide
77
wheel_convert
88
wheel_unpack
99
wheel_pack
10+
wheel_tags

docs/reference/wheel_tags.rst

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
wheel tags
2+
==========
3+
4+
Usage
5+
-----
6+
7+
::
8+
9+
wheel tags [-h] [--remove] [--python-tag TAG] [--abi-tag TAG] [--platform-tag TAG] [--build NUMBER] WHEEL [...]
10+
11+
Description
12+
-----------
13+
14+
Make a new wheel with given tags from and existing wheel. Any tags left
15+
unspecified will remain the same. Multiple tags are separated by a "." Starting
16+
with a "+" will append to the existing tags. Starting with a "-" will remove a
17+
tag. Be sure to use the equals syntax on the shell so that it does not get
18+
parsed as an extra option, such as ``--python-tag=-py2``. The original file
19+
will remain unless ``--remove`` is given. The output filename(s) will be
20+
displayed on stdout for further processing.
21+
22+
23+
Options
24+
-------
25+
26+
.. option:: --remove
27+
28+
Remove the original wheel, keeping only the retagged wheel.
29+
30+
.. option:: --python-tag=TAG
31+
32+
Override the python tag (prepend with "+" to append, "-" to remove).
33+
Multiple tags can be separated with a dot.
34+
35+
.. option:: --abi-tag=TAG
36+
37+
Override the abi tag (prepend with "+" to append, "-" to remove).
38+
Multiple tags can be separated with a dot.
39+
40+
.. option:: --platform-tag=TAG
41+
42+
Override the platform tag (prepend with "+" to append, "-" to remove).
43+
Multiple tags can be separated with a dot.
44+
45+
.. option:: --build=NUMBER
46+
47+
Specify a build number.
48+
49+
Examples
50+
--------
51+
52+
* Replace a wheel's Python specific tags with generic tags (if no Python extensions are present, for example)::
53+
54+
$ wheel tags --python-tag=py2.py3 --abi-tag=none cmake-3.20.2-cp39-cp39-win_amd64.whl
55+
cmake-3.20.2-py2.py3-none-win_amd64.whl
56+
57+
* Add compatibility tags for macOS universal wheels and older pips::
58+
59+
$ wheel tags \
60+
--platform-tag=+macosx_10_9_x86_64.macosx_11_0_arm64 \
61+
ninja-1.11.1-py2.py3-none-macosx_10_9_universal2.whl
62+
ninja-1.11.1-py2.py3-none-macosx_10_9_universal2.macosx_10_9_x86_64.macosx_11_0_arm64.whl

src/wheel/cli/__init__.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,40 @@ def convert_f(args):
3131
convert(args.files, args.dest_dir, args.verbose)
3232

3333

34+
def tags_f(args):
35+
from .tags import tags
36+
37+
names = (
38+
tags(
39+
wheel,
40+
args.python_tag,
41+
args.abi_tag,
42+
args.platform_tag,
43+
args.build,
44+
args.remove,
45+
)
46+
for wheel in args.wheel
47+
)
48+
49+
for name in names:
50+
print(name)
51+
52+
3453
def version_f(args):
3554
from .. import __version__
3655

3756
print("wheel %s" % __version__)
3857

3958

59+
TAGS_HELP = """\
60+
Make a new wheel with given tags. Any tags unspecified will remain the same.
61+
Starting the tags with a "+" will append to the existing tags. Starting with a
62+
"-" will remove a tag (use --option=-TAG syntax). Multiple tags can be
63+
separated by ".". The original file will remain unless --remove is given. The
64+
output filename(s) will be displayed on stdout for further processing.
65+
"""
66+
67+
4068
def parser():
4169
p = argparse.ArgumentParser()
4270
s = p.add_subparsers(help="commands")
@@ -72,6 +100,27 @@ def parser():
72100
convert_parser.add_argument("--verbose", "-v", action="store_true")
73101
convert_parser.set_defaults(func=convert_f)
74102

103+
tags_parser = s.add_parser(
104+
"tags", help="Add or replace the tags on a wheel", description=TAGS_HELP
105+
)
106+
tags_parser.add_argument("wheel", nargs="*", help="Existing wheel(s) to retag")
107+
tags_parser.add_argument(
108+
"--remove",
109+
action="store_true",
110+
help="Remove the original files, keeping only the renamed ones",
111+
)
112+
tags_parser.add_argument(
113+
"--python-tag", metavar="TAG", help="Specify an interpreter tag(s)"
114+
)
115+
tags_parser.add_argument("--abi-tag", metavar="TAG", help="Specify an ABI tag(s)")
116+
tags_parser.add_argument(
117+
"--platform-tag", metavar="TAG", help="Specify a platform tag(s)"
118+
)
119+
tags_parser.add_argument(
120+
"--build", type=int, metavar="NUMBER", help="Specify a build number"
121+
)
122+
tags_parser.set_defaults(func=tags_f)
123+
75124
version_parser = s.add_parser("version", help="Print version and exit")
76125
version_parser.set_defaults(func=version_f)
77126

src/wheel/cli/pack.py

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,7 @@ def pack(directory: str, dest_dir: str, build_number: str | None):
3838
existing_build_number = None
3939
wheel_file_path = os.path.join(directory, dist_info_dir, "WHEEL")
4040
with open(wheel_file_path, encoding="utf-8") as f:
41-
tags = []
42-
for line in f:
43-
if line.startswith("Tag: "):
44-
tags.append(line.split(" ")[1].rstrip())
45-
elif line.startswith("Build: "):
46-
existing_build_number = line.split(" ")[1].rstrip()
41+
tags, existing_build_number = read_tags(f.read())
4742

4843
if not tags:
4944
raise WheelError(
@@ -58,28 +53,16 @@ def pack(directory: str, dest_dir: str, build_number: str | None):
5853
name_version += "-" + build_number
5954

6055
if build_number != existing_build_number:
61-
replacement = (
62-
("Build: %s\r\n" % build_number).encode("ascii")
63-
if build_number
64-
else b""
65-
)
6656
with open(wheel_file_path, "rb+") as f:
6757
wheel_file_content = f.read()
68-
wheel_file_content, num_replaced = BUILD_NUM_RE.subn(
69-
replacement, wheel_file_content
70-
)
71-
if not num_replaced:
72-
wheel_file_content += replacement
58+
wheel_file_content = set_build_number(wheel_file_content, build_number)
7359

7460
f.seek(0)
7561
f.truncate()
7662
f.write(wheel_file_content)
7763

7864
# Reassemble the tags for the wheel file
79-
impls = sorted({tag.split("-")[0] for tag in tags})
80-
abivers = sorted({tag.split("-")[1] for tag in tags})
81-
platforms = sorted({tag.split("-")[2] for tag in tags})
82-
tagline = "-".join([".".join(impls), ".".join(abivers), ".".join(platforms)])
65+
tagline = compute_tagline(tags)
8366

8467
# Repack the wheel
8568
wheel_path = os.path.join(dest_dir, f"{name_version}-{tagline}.whl")
@@ -88,3 +71,54 @@ def pack(directory: str, dest_dir: str, build_number: str | None):
8871
wf.write_files(directory)
8972

9073
print("OK")
74+
75+
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+
115+
def compute_tagline(tags: list[str]) -> str:
116+
"""Compute a tagline from a list of tags.
117+
118+
:param tags: A list of tags
119+
:return: A tagline
120+
"""
121+
impls = sorted({tag.split("-")[0] for tag in tags})
122+
abivers = sorted({tag.split("-")[1] for tag in tags})
123+
platforms = sorted({tag.split("-")[2] for tag in tags})
124+
return "-".join([".".join(impls), ".".join(abivers), ".".join(platforms)])

src/wheel/cli/tags.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from __future__ import annotations
2+
3+
import itertools
4+
import os
5+
from collections.abc import Iterable
6+
7+
from ..wheelfile import WheelFile
8+
from .pack import read_tags, set_build_number
9+
10+
11+
def _compute_tags(original_tags: Iterable[str], new_tags: str | None) -> set[str]:
12+
"""Add or replace tags. Supports dot-separated tags"""
13+
if new_tags is None:
14+
return set(original_tags)
15+
16+
if new_tags.startswith("+"):
17+
return {*original_tags, *new_tags[1:].split(".")}
18+
19+
if new_tags.startswith("-"):
20+
return set(original_tags) - set(new_tags[1:].split("."))
21+
22+
return set(new_tags.split("."))
23+
24+
25+
def tags(
26+
wheel: str,
27+
python_tags: str | None = None,
28+
abi_tags: str | None = None,
29+
platform_tags: str | None = None,
30+
build_number: int | None = None,
31+
remove: bool = False,
32+
) -> str:
33+
"""Change the tags on a wheel file.
34+
35+
The tags are left unchanged if they are not specified. To specify "none",
36+
use ["none"]. To append to the previous tags, a tag should start with a
37+
"+". If a tag starts with "-", it will be removed from existing tags.
38+
Processing is done left to right.
39+
40+
:param wheel: The paths to the wheels
41+
:param python_tags: The Python tags to set
42+
:param abi_tags: The ABI tags to set
43+
:param platform_tags: The platform tags to set
44+
:param build_number: The build number to set
45+
:param remove: Remove the original wheel
46+
"""
47+
with WheelFile(wheel, "r") as f:
48+
assert f.filename, f"{f.filename} must be available"
49+
50+
wheel_info = f.read(f.dist_info_path + "/WHEEL")
51+
52+
original_wheel_name = os.path.basename(f.filename)
53+
namever = f.parsed_filename.group("namever")
54+
build = f.parsed_filename.group("build")
55+
original_python_tags = f.parsed_filename.group("pyver").split(".")
56+
original_abi_tags = f.parsed_filename.group("abi").split(".")
57+
original_plat_tags = f.parsed_filename.group("plat").split(".")
58+
59+
tags, existing_build_number = read_tags(wheel_info)
60+
61+
impls = {tag.split("-")[0] for tag in tags}
62+
abivers = {tag.split("-")[1] for tag in tags}
63+
platforms = {tag.split("-")[2] for tag in tags}
64+
65+
if impls != set(original_python_tags):
66+
msg = f"Wheel internal tags {impls!r} != filename tags {original_python_tags!r}"
67+
raise AssertionError(msg)
68+
69+
if abivers != set(original_abi_tags):
70+
msg = f"Wheel internal tags {abivers!r} != filename tags {original_abi_tags!r}"
71+
raise AssertionError(msg)
72+
73+
if platforms != set(original_plat_tags):
74+
msg = (
75+
f"Wheel internal tags {platforms!r} != filename tags {original_plat_tags!r}"
76+
)
77+
raise AssertionError(msg)
78+
79+
if existing_build_number != build:
80+
msg = (
81+
f"Incorrect filename '{build}' "
82+
"& *.dist-info/WHEEL '{existing_build_number}' build numbers"
83+
)
84+
raise AssertionError(msg)
85+
86+
# Start changing as needed
87+
if build_number is not None:
88+
build = str(build_number)
89+
90+
final_python_tags = sorted(_compute_tags(original_python_tags, python_tags))
91+
final_abi_tags = sorted(_compute_tags(original_abi_tags, abi_tags))
92+
final_plat_tags = sorted(_compute_tags(original_plat_tags, platform_tags))
93+
94+
final_tags = [
95+
namever,
96+
".".join(final_python_tags),
97+
".".join(final_abi_tags),
98+
".".join(final_plat_tags),
99+
]
100+
if build:
101+
final_tags.insert(1, build)
102+
103+
final_wheel_name = "-".join(final_tags) + ".whl"
104+
105+
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+
]
112+
113+
original_wheel_path = os.path.join(
114+
os.path.dirname(f.filename), original_wheel_name
115+
)
116+
final_wheel_path = os.path.join(os.path.dirname(f.filename), final_wheel_name)
117+
118+
with WheelFile(original_wheel_path, "r") as fin, WheelFile(
119+
final_wheel_path, "w"
120+
) as fout:
121+
fout.comment = fin.comment # preserve the comment
122+
for item in fin.infolist():
123+
if item.filename == f.dist_info_path + "/RECORD":
124+
continue
125+
if item.filename == f.dist_info_path + "/WHEEL":
126+
content = fin.read(item)
127+
content = set_tags(content, tags)
128+
content = set_build_number(content, build)
129+
fout.writestr(item, content)
130+
else:
131+
fout.writestr(item, fin.read(item))
132+
133+
if remove:
134+
os.remove(original_wheel_path)
135+
136+
return final_wheel_name
137+
138+
139+
def set_tags(in_string: bytes, tags: Iterable[str]) -> bytes:
140+
"""Set the tags in the .dist-info/WHEEL file contents.
141+
142+
:param in_string: The string to modify.
143+
:param tags: The tags to set.
144+
"""
145+
146+
lines = [line for line in in_string.splitlines() if not line.startswith(b"Tag:")]
147+
for tag in tags:
148+
lines.append(b"Tag: " + tag.encode("ascii"))
149+
in_string = b"\r\n".join(lines) + b"\r\n"
150+
151+
return in_string

0 commit comments

Comments
 (0)