Repository: scikit-build/scikit-build-core (python)

Issue:
Wheel build tag support
Hello, 

According to the https://peps.python.org/pep-0491/ the wheel name may include {build tag}, which is useful when the wheel packaging is part of CI, do you have a plans to support it?

The wheel filename is {distribution}-{version}(**-{build tag}**)?-{python tag}-{abi tag}-{platform tag}.whl.

Thanks, 
Mikhail

Pull request description:
feat: add build-tag
Fix #611.

- [x] Make sure the build tag is present in the metadata too.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7d77038..ebfe675 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -22,7 +22,7 @@ repos:
         exclude: "^tests"
 
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.1.9
+    rev: v0.1.13
     hooks:
       - id: ruff
         args: ["--fix", "--show-fixes"]
diff --git a/README.md b/README.md
index 41fd32e..eead524 100644
--- a/README.md
+++ b/README.md
@@ -252,6 +252,9 @@ wheel.platlib = ""
 # Editable installs may not respect this exclusion.
 wheel.exclude = []
 
+# The build tag to use for the wheel. If empty, no build tag is used.
+wheel.build-tag = ""
+
 # If CMake is less than this value, backport a copy of FindPython. Set to 0
 # disable this, or the empty string.
 backport.find-python = "3.26.1"
diff --git a/docs/cmakelists.md b/docs/cmakelists.md
index aa3ae2e..0681186 100644
--- a/docs/cmakelists.md
+++ b/docs/cmakelists.md
@@ -14,7 +14,10 @@ the version of scikit-build-core with `${SKBUILD_CORE_VERSION}`.
 Scikit-build-core provides several useful variables:
 
 - `${SKBUILD_PROJECT_NAME}`: The name of the project.
-- `${SKBUILD_PROJECT_VERSION}`: The version of the project.
+- `${SKBUILD_PROJECT_VERSION}`: The version of the project in a form CMake can
+  use.
+- `${SKBUILD_PROJECT_VERSION_FULL}`: The exact version of the project including
+  dev & local suffix.
 - `${SKBUILD_STATE}`: The run state, one of `sdist`, `wheel`, `metadata_wheel`,
   `editable`, or `metadata_editable`.
 
diff --git a/src/scikit_build_core/build/_wheelfile.py b/src/scikit_build_core/build/_wheelfile.py
index 53398b5..4eefd57 100644
--- a/src/scikit_build_core/build/_wheelfile.py
+++ b/src/scikit_build_core/build/_wheelfile.py
@@ -24,7 +24,6 @@ if TYPE_CHECKING:
     from collections.abc import Mapping, Set
 
     from packaging.tags import Tag
-    from packaging.utils import BuildTag
     from pyproject_metadata import StandardMetadata
 
     from .._compat.typing import Self
@@ -50,8 +49,8 @@ class WheelMetadata:
     root_is_purelib: bool = False
     metadata_version: str = "1.0"
     generator: str = f"scikit-build-core {__version__}"
-    build_tag: BuildTag = ()
     tags: Set[Tag] = dataclasses.field(default_factory=frozenset)
+    build_tag: str = ""
 
     def as_bytes(self) -> bytes:
         msg = Message(policy=EMAIL_POLICY)
@@ -59,11 +58,11 @@ class WheelMetadata:
         msg["Wheel-Version"] = self.metadata_version
         msg["Generator"] = self.generator
         msg["Root-Is-Purelib"] = str(self.root_is_purelib).lower()
-        if self.build_tag:
-            msg["Build"] = str(self.build_tag[0]) + self.build_tag[1]
 
         for tag in sorted(self.tags, key=lambda t: (t.interpreter, t.abi, t.platform)):
             msg["Tag"] = f"{tag.interpreter}-{tag.abi}-{tag.platform}"
+        if self.build_tag:
+            msg["Build"] = self.build_tag
 
         return msg.as_bytes()
 
@@ -76,10 +75,16 @@ class WheelWriter:
     folder: Path
     tags: Set[Tag]
     wheel_metadata = WheelMetadata(root_is_purelib=False)
-    buildver: str = ""
+    build_tag: str = ""
     license_files: Mapping[Path, bytes] = dataclasses.field(default_factory=dict)
     _zipfile: zipfile.ZipFile | None = None
 
+    def __post_init__(self) -> None:
+        if self.build_tag:
+            self.wheel_metadata = dataclasses.replace(
+                self.wheel_metadata, build_tag=self.build_tag
+            )
+
     @property
     def name_ver(self) -> str:
         name = packaging.utils.canonicalize_name(self.metadata.name).replace("-", "_")
@@ -92,7 +97,7 @@ class WheelWriter:
         pyver = ".".join(sorted({t.interpreter for t in self.tags}))
         abi = ".".join(sorted({t.abi for t in self.tags}))
         arch = ".".join(sorted({t.platform for t in self.tags}))
-        optbuildver = [self.buildver] if self.buildver else []
+        optbuildver = [self.build_tag] if self.build_tag else []
         return "-".join([self.name_ver, *optbuildver, pyver, abi, arch])
 
     @property
diff --git a/src/scikit_build_core/build/wheel.py b/src/scikit_build_core/build/wheel.py
index 12dd7f2..04454de 100644
--- a/src/scikit_build_core/build/wheel.py
+++ b/src/scikit_build_core/build/wheel.py
@@ -173,6 +173,7 @@ def _build_wheel_impl(
             archs_to_tags(get_archs(os.environ)),
             settings.wheel.py_api,
             expand_macos=settings.wheel.expand_macos_universal_tags,
+            build_tag=settings.wheel.build_tag,
         )
 
         # A build dir can be specified, otherwise use a temporary directory
@@ -245,6 +246,7 @@ def _build_wheel_impl(
                 metadata,
                 Path(metadata_directory),
                 tags.as_tags_set(),
+                build_tag=settings.wheel.build_tag,
                 license_files=license_files,
             )
             dist_info_contents = wheel.dist_info_contents()
@@ -352,6 +354,7 @@ def _build_wheel_impl(
             Path(wheel_directory),
             tags.as_tags_set(),
             license_files=license_files,
+            build_tag=settings.wheel.build_tag,
         ) as wheel:
             wheel.build(wheel_dirs)
 
diff --git a/src/scikit_build_core/builder/builder.py b/src/scikit_build_core/builder/builder.py
index 75ce56e..8f3fe75 100644
--- a/src/scikit_build_core/builder/builder.py
+++ b/src/scikit_build_core/builder/builder.py
@@ -157,7 +157,8 @@ class Builder:
             canonical_name = name.replace("-", "_").replace(".", "_")
             cache_config["SKBUILD_PROJECT_NAME"] = canonical_name
         if version is not None:
-            cache_config["SKBUILD_PROJECT_VERSION"] = str(version)
+            cache_config["SKBUILD_PROJECT_VERSION"] = version.base_version
+            cache_config["SKBUILD_PROJECT_VERSION_FULL"] = str(version)
 
         if limited_abi is None:
             if self.settings.wheel.py_api.startswith("cp3"):
diff --git a/src/scikit_build_core/builder/wheel_tag.py b/src/scikit_build_core/builder/wheel_tag.py
index 9f28381..3e6a006 100644
--- a/src/scikit_build_core/builder/wheel_tag.py
+++ b/src/scikit_build_core/builder/wheel_tag.py
@@ -27,6 +27,7 @@ class WheelTag:
     pyvers: list[str]
     abis: list[str]
     archs: list[str]
+    build_tag: str = ""
 
     # TODO: plats only used on macOS & Windows
     @classmethod
@@ -36,7 +37,16 @@ class WheelTag:
         py_api: str = "",
         *,
         expand_macos: bool = False,
+        build_tag: str = "",
     ) -> Self:
+        if build_tag:
+            if not build_tag[0].isdigit():
+                msg = f"Unexpected build-tag, must start with a digit, {build_tag!r} invalid"
+                raise AssertionError(msg)
+            if "-" in build_tag:
+                msg = f"Unexpected build-tag, {build_tag!r} cannot contain dashes"
+                raise AssertionError(msg)
+
         best_tag = next(packaging.tags.sys_tags())
         interp, abi, *plats = (best_tag.interpreter, best_tag.abi, best_tag.platform)
         pyvers = [interp]
@@ -90,7 +100,7 @@ class WheelTag:
                 msg = f"Unexpected py-api, must be abi3 (e.g. cp39) or Pythonless (e.g. py2.py3), not {py_api}"
                 raise AssertionError(msg)
 
-        return cls(pyvers=pyvers, abis=[abi], archs=plats)
+        return cls(pyvers=pyvers, abis=[abi], archs=plats, build_tag=build_tag)
 
     @property
     def pyver(self) -> str:
@@ -105,6 +115,9 @@ class WheelTag:
         return ".".join(self.archs)
 
     def __str__(self) -> str:
+        if self.build_tag:
+            return f"{self.build_tag}-{self.pyver}-{self.abi}-{self.arch}"
+
         return f"{self.pyver}-{self.abi}-{self.arch}"
 
     def tags_dict(self) -> dict[str, list[str]]:
diff --git a/src/scikit_build_core/resources/scikit-build.schema.json b/src/scikit_build_core/resources/scikit-build.schema.json
index e6721e3..0033346 100644
--- a/src/scikit_build_core/resources/scikit-build.schema.json
+++ b/src/scikit_build_core/resources/scikit-build.schema.json
@@ -183,6 +183,11 @@
             "type": "string"
           },
           "description": "A set of patterns to exclude from the wheel. This is additive to the SDist exclude patterns. This applies to the source files, not the final paths. Editable installs may not respect this exclusion."
+        },
+        "build-tag": {
+          "type": "string",
+          "default": "",
+          "description": "The build tag to use for the wheel. If empty, no build tag is used."
         }
       }
     },
diff --git a/src/scikit_build_core/settings/skbuild_model.py b/src/scikit_build_core/settings/skbuild_model.py
index 51eebae..bfd6dc1 100644
--- a/src/scikit_build_core/settings/skbuild_model.py
+++ b/src/scikit_build_core/settings/skbuild_model.py
@@ -195,6 +195,11 @@ class WheelSettings:
     Editable installs may not respect this exclusion.
     """
 
+    build_tag: str = ""
+    """
+    The build tag to use for the wheel. If empty, no build tag is used.
+    """
+
 
 @dataclasses.dataclass
 class BackportSettings:


Test patch:
diff --git a/tests/test_builder.py b/tests/test_builder.py
index e0d9775..e4a8476 100644
--- a/tests/test_builder.py
+++ b/tests/test_builder.py
@@ -123,6 +123,17 @@ def test_wheel_tag(monkeypatch, minver, archs, answer):
     assert plat == answer
 
 
+@pytest.mark.parametrize("archs", ["x86_64" "arm64" "universal2"])
+def test_wheel_build_tag(monkeypatch, archs):
+    monkeypatch.setattr(sys, "platform", "darwin")
+    monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.12")
+    monkeypatch.setattr(platform, "mac_ver", lambda: ("10.9.2", "", ""))
+
+    tags = WheelTag.compute_best(archs, build_tag="1")
+    answer = str(tags).split("-")[0]
+    assert answer == "1"
+
+
 def test_wheel_tag_expand(monkeypatch):
     monkeypatch.setattr(sys, "platform", "darwin")
     monkeypatch.setenv("MACOSX_DEPLOYMENT_TARGET", "10.10")
diff --git a/tests/test_pyproject_pep517.py b/tests/test_pyproject_pep517.py
index 1ff59af..231b66c 100644
--- a/tests/test_pyproject_pep517.py
+++ b/tests/test_pyproject_pep517.py
@@ -207,7 +207,7 @@ def test_pep517_wheel(virtualenv):
 @pytest.mark.usefixtures("package_simple_pyproject_source_dir")
 def test_pep517_wheel_source_dir(virtualenv):
     dist = Path("dist")
-    out = build_wheel("dist")
+    out = build_wheel("dist", config_settings={"skbuild.wheel.build-tag": "1foo"})
     (wheel,) = dist.glob("cmake_example-0.0.1-*.whl")
     assert wheel == dist / out
 
@@ -216,6 +216,9 @@ def test_pep517_wheel_source_dir(virtualenv):
             p = zipfile.Path(f)
             file_names = [p.name for p in p.iterdir()]
             metadata = p.joinpath("cmake_example-0.0.1.dist-info/METADATA").read_text()
+            wheel_metadata = p.joinpath(
+                "cmake_example-0.0.1.dist-info/WHEEL"
+            ).read_text()
             entry_points = p.joinpath(
                 "cmake_example-0.0.1.dist-info/entry_points.txt"
             ).read_text()
@@ -237,6 +240,11 @@ def test_pep517_wheel_source_dir(virtualenv):
         assert "Requires-Python: >=3.7" in metadata
         assert "Provides-Extra: test" in metadata
 
+        assert "Build: 1foo" in wheel_metadata
+        assert "Wheel-Version: 1.0" in wheel_metadata
+        assert "Generator: scikit-build" in wheel_metadata
+        assert "Root-Is-Purelib: false" in wheel_metadata
+
     virtualenv.install(wheel)
 
     version = virtualenv.execute(
diff --git a/tests/test_skbuild_settings.py b/tests/test_skbuild_settings.py
index b037e8f..8df7d15 100644
--- a/tests/test_skbuild_settings.py
+++ b/tests/test_skbuild_settings.py
@@ -52,6 +52,7 @@ def test_skbuild_settings_default(tmp_path: Path):
         "AUTHORS*",
     ]
     assert settings.wheel.exclude == []
+    assert settings.wheel.build_tag == ""
     assert settings.backport.find_python == Version("3.26.1")
     assert settings.strict_config
     assert not settings.experimental
@@ -89,6 +90,7 @@ def test_skbuild_settings_envvar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch
     monkeypatch.setenv("SKBUILD_WHEEL_EXPAND_MACOS_UNIVERSAL_TAGS", "True")
     monkeypatch.setenv("SKBUILD_WHEEL_LICENSE_FILES", "a;b;c")
     monkeypatch.setenv("SKBUILD_WHEEL_EXCLUDE", "b;y;e")
+    monkeypatch.setenv("SKBUILD_WHEEL_BUILD_TAG", "1")
     monkeypatch.setenv("SKBUILD_BACKPORT_FIND_PYTHON", "0")
     monkeypatch.setenv("SKBUILD_STRICT_CONFIG", "0")
     monkeypatch.setenv("SKBUILD_EXPERIMENTAL", "1")
@@ -128,6 +130,7 @@ def test_skbuild_settings_envvar(tmp_path: Path, monkeypatch: pytest.MonkeyPatch
     assert settings.wheel.expand_macos_universal_tags
     assert settings.wheel.license_files == ["a", "b", "c"]
     assert settings.wheel.exclude == ["b", "y", "e"]
+    assert settings.wheel.build_tag == "1"
     assert settings.backport.find_python == Version("0")
     assert not settings.strict_config
     assert settings.experimental
@@ -173,6 +176,7 @@ def test_skbuild_settings_config_settings(
         "wheel.expand-macos-universal-tags": "True",
         "wheel.license-files": ["a", "b", "c"],
         "wheel.exclude": ["b", "y", "e"],
+        "wheel.build-tag": "1foo",
         "backport.find-python": "0",
         "strict-config": "false",
         "experimental": "1",
@@ -211,6 +215,7 @@ def test_skbuild_settings_config_settings(
     assert settings.wheel.expand_macos_universal_tags
     assert settings.wheel.license_files == ["a", "b", "c"]
     assert settings.wheel.exclude == ["b", "y", "e"]
+    assert settings.wheel.build_tag == "1foo"
     assert settings.backport.find_python == Version("0")
     assert not settings.strict_config
     assert settings.experimental
@@ -254,6 +259,7 @@ def test_skbuild_settings_pyproject_toml(
             wheel.expand-macos-universal-tags = true
             wheel.license-files = ["a", "b", "c"]
             wheel.exclude = ["b", "y", "e"]
+            wheel.build-tag = "1_bar"
             backport.find-python = "3.18"
             strict-config = false
             experimental = true
@@ -302,6 +308,7 @@ def test_skbuild_settings_pyproject_toml(
     assert settings.wheel.expand_macos_universal_tags
     assert settings.wheel.license_files == ["a", "b", "c"]
     assert settings.wheel.exclude == ["b", "y", "e"]
+    assert settings.wheel.build_tag == "1_bar"
     assert settings.backport.find_python == Version("3.18")
     assert not settings.strict_config
     assert settings.experimental