Repository: pypa/wheel

Issue:
Please allow full Python build tag syntax, including non-numeric data
Python defines build tag nomenclature that would work out very well for preparing builds for a release, except that many tools, like `setuptool` fail to generate build tags, even though their syntax describes that it's possible. Fortunately, `wheel` just introduced `wheel tags --build` (thank you for that), but only supports numeric build numbers, which provides limited usability.

Here's Python's description of the wheel naming nomenclature:

https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-format

, which looks like this:

    {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl

> Optional build number. Must start with a digit. ... two-item tuple with the first item being the initial digits as an int, and the second item being the remainder of the tag as a str

Just for completeness, `setuptools` describe their syntax here. They confirmed that it's eggs that are deprecated, not build tags syntax, which simply doesn't work.

https://setuptools.pypa.io/en/latest/deprecated/commands.html#release-tagging-options

What I have been doing since I was able to update to `wheel` version 0.40.0 is building a wheel package and then using 

    wheel tags --build 123 dist\abc-4.5.6-py3-none-any.whl

, and it yields `abc-4.5.6-123-py3-none-any.whl`. This is great, but when CI runs against feature branches, there is no way to qualify builds with a branch name, so packages are being produced on a feature branch may appear more stable (i.e. greater build number) once they are downloaded by QA, but they likely to be far from it, being it a feature branch.

It would be great if `wheel` allowed the string component of the Python build tag. This way packages built in feature branches can be easily distinguished from those built on main or release branches.

NOTE: One important thing I want to mention is that build tags in this context are not intended for production package repositories, which is how many of these discussions are interpreted, but rather as a measure of a build maturity before some release, making packages that increase in quality/features with increasing build numbers. Artifactory is a good example of this - not a production repository, but a development one.

Discussion:

Patch:
diff --git a/.cirrus.yml b/.cirrus.yml
index eed71be..7df7d08 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -1,5 +1,5 @@
 freebsd_instance:
-  image_family: freebsd-13-0-snap
+  image_family: freebsd-13-1
 
 test_task:
   only_if: "$CIRRUS_BRANCH == 'main' || $CIRRUS_PR != ''"
diff --git a/docs/news.rst b/docs/news.rst
index 0ddc562..97d7b65 100644
--- a/docs/news.rst
+++ b/docs/news.rst
@@ -1,6 +1,11 @@
 Release Notes
 =============
 
+**UNRELEASED**
+
+- Added full support of the build tag syntax to ``wheel tags`` (you can now set a build
+  tag like ``123mytag``)
+
 **0.40.0 (2023-03-14)**
 
 - Added a ``wheel tags`` command to modify tags on an existing wheel
diff --git a/src/wheel/cli/__init__.py b/src/wheel/cli/__init__.py
index fa1f10b..c09f839 100644
--- a/src/wheel/cli/__init__.py
+++ b/src/wheel/cli/__init__.py
@@ -7,6 +7,7 @@ from __future__ import annotations
 import argparse
 import os
 import sys
+from argparse import ArgumentTypeError
 
 
 class WheelError(Exception):
@@ -56,6 +57,15 @@ def version_f(args):
     print("wheel %s" % __version__)
 
 
+def parse_build_tag(build_tag: str) -> str:
+    if not build_tag[0].isdigit():
+        raise ArgumentTypeError("build tag must begin with a digit")
+    elif "-" in build_tag:
+        raise ArgumentTypeError("invalid character ('-') in build tag")
+
+    return build_tag
+
+
 TAGS_HELP = """\
 Make a new wheel with given tags. Any tags unspecified will remain the same.
 Starting the tags with a "+" will append to the existing tags. Starting with a
@@ -117,7 +127,7 @@ def parser():
         "--platform-tag", metavar="TAG", help="Specify a platform tag(s)"
     )
     tags_parser.add_argument(
-        "--build", type=int, metavar="NUMBER", help="Specify a build number"
+        "--build", type=parse_build_tag, metavar="BUILD", help="Specify a build tag"
     )
     tags_parser.set_defaults(func=tags_f)
 
diff --git a/src/wheel/cli/tags.py b/src/wheel/cli/tags.py
index 0ea0f44..b9094d7 100644
--- a/src/wheel/cli/tags.py
+++ b/src/wheel/cli/tags.py
@@ -27,7 +27,7 @@ def tags(
     python_tags: str | None = None,
     abi_tags: str | None = None,
     platform_tags: str | None = None,
-    build_number: int | None = None,
+    build_tag: str | None = None,
     remove: bool = False,
 ) -> str:
     """Change the tags on a wheel file.
@@ -41,7 +41,7 @@ def tags(
     :param python_tags: The Python tags to set
     :param abi_tags: The ABI tags to set
     :param platform_tags: The platform tags to set
-    :param build_number: The build number to set
+    :param build_tag: The build tag to set
     :param remove: Remove the original wheel
     """
     with WheelFile(wheel, "r") as f:
@@ -56,7 +56,7 @@ def tags(
         original_abi_tags = f.parsed_filename.group("abi").split(".")
         original_plat_tags = f.parsed_filename.group("plat").split(".")
 
-    tags, existing_build_number = read_tags(wheel_info)
+    tags, existing_build_tag = read_tags(wheel_info)
 
     impls = {tag.split("-")[0] for tag in tags}
     abivers = {tag.split("-")[1] for tag in tags}
@@ -76,16 +76,16 @@ def tags(
         )
         raise AssertionError(msg)
 
-    if existing_build_number != build:
+    if existing_build_tag != build:
         msg = (
             f"Incorrect filename '{build}' "
-            "& *.dist-info/WHEEL '{existing_build_number}' build numbers"
+            f"& *.dist-info/WHEEL '{existing_build_tag}' build numbers"
         )
         raise AssertionError(msg)
 
     # Start changing as needed
-    if build_number is not None:
-        build = str(build_number)
+    if build_tag is not None:
+        build = build_tag
 
     final_python_tags = sorted(_compute_tags(original_python_tags, python_tags))
     final_abi_tags = sorted(_compute_tags(original_abi_tags, abi_tags))


Test patch:
diff --git a/tests/cli/test_tags.py b/tests/cli/test_tags.py
index 630c903..2454149 100644
--- a/tests/cli/test_tags.py
+++ b/tests/cli/test_tags.py
@@ -1,12 +1,13 @@
 from __future__ import annotations
 
 import shutil
+import sys
 from pathlib import Path
 from zipfile import ZipFile
 
 import pytest
 
-from wheel.cli import parser
+from wheel.cli import main, parser
 from wheel.cli.tags import tags
 from wheel.wheelfile import WheelFile
 
@@ -110,20 +111,37 @@ def test_plat_tags(wheelpath):
     assert TESTWHEEL_NAME == newname
 
 
-def test_build_number(wheelpath):
-    newname = tags(str(wheelpath), build_number=1)
-    assert TESTWHEEL_NAME.replace("-py2", "-1-py2") == newname
+def test_build_tag(wheelpath):
+    newname = tags(str(wheelpath), build_tag="1bah")
+    assert TESTWHEEL_NAME.replace("-py2", "-1bah-py2") == newname
     output_file = wheelpath.parent / newname
     assert output_file.exists()
     output_file.unlink()
 
 
+@pytest.mark.parametrize(
+    "build_tag, error",
+    [
+        pytest.param("foo", "build tag must begin with a digit", id="digitstart"),
+        pytest.param("1-f", "invalid character ('-') in build tag", id="hyphen"),
+    ],
+)
+def test_invalid_build_tag(wheelpath, build_tag, error, monkeypatch, capsys):
+    monkeypatch.setattr(sys, "argv", [sys.argv[0], "tags", "--build", build_tag])
+    with pytest.raises(SystemExit) as exc:
+        main()
+
+    _, err = capsys.readouterr()
+    assert exc.value.args[0] == 2
+    assert f"error: argument --build: {error}" in err
+
+
 def test_multi_tags(wheelpath):
     newname = tags(
         str(wheelpath),
         platform_tags="linux_x86_64",
         python_tags="+py4",
-        build_number=1,
+        build_tag="1",
     )
     assert "test-1.0-1-py2.py3.py4-none-linux_x86_64.whl" == newname