Repository: pypa/setuptools

Issue:
Wrong Version detected with project_wheel_metadata 
Hello,

I'm not sure whether this is a problem with `build`, `pep517`, `importlib.metadata` or something different.

I triaged a potential bug in poetry 1.2.0a2 and realized the following:

When I build a wheel for the current `sqlalchemy` from it's [main branch](https://github.com/sqlalchemy/sqlalchemy) - after cloning the repo - by `python -m build` the `Version` field in `METADATA` is `2.0.0b1.dev0`.

But when I run this in a python console the result is different:

```py
import build.util
meta = build.util.project_wheel_metadata(".")
meta.get("Version")
'2.0.0b1.dev0dev'
```

As you can see there's an additional `dev` at the end of the version.

Any ideas what's going on?

Python: 3.10.2
build: 0.7.0

fin swimmer




Discussion:
`project_wheel_metadata` doesn't build an intermediate sdist so I'd start by checking what the version for `python -m build -w` is.
Okay, the `prepare_metadata_for_build_wheel` hook produces `'2.0.0b1.dev0dev'`.  This is most likely a bug in how `dist_info` processes `tag_build` - I can reproduce it with `python setup.py dist_info`.
Yeah, it seems like a bug in setuptools.

@jaraco could you transfer the issue?
A similar issue was [reported in this comment](https://github.com/pypa/setuptools/pull/2872#issuecomment-966518478). There may already be a bug reported about it. I'd appreciate help to triage.

Resolving patch:
diff --git a/setuptools/command/dist_info.py b/setuptools/command/dist_info.py
--- a/setuptools/command/dist_info.py
+++ b/setuptools/command/dist_info.py
@@ -4,9 +4,13 @@
 """
 
 import os
+import re
+import warnings
+from inspect import cleandoc
 
 from distutils.core import Command
 from distutils import log
+from setuptools.extern import packaging
 
 
 class dist_info(Command):
@@ -29,8 +33,36 @@ def run(self):
         egg_info.egg_base = self.egg_base
         egg_info.finalize_options()
         egg_info.run()
-        dist_info_dir = egg_info.egg_info[:-len('.egg-info')] + '.dist-info'
+        name = _safe(self.distribution.get_name())
+        version = _version(self.distribution.get_version())
+        base = self.egg_base or os.curdir
+        dist_info_dir = os.path.join(base, f"{name}-{version}.dist-info")
         log.info("creating '{}'".format(os.path.abspath(dist_info_dir)))
 
         bdist_wheel = self.get_finalized_command('bdist_wheel')
         bdist_wheel.egg2dist(egg_info.egg_info, dist_info_dir)
+
+
+def _safe(component: str) -> str:
+    """Escape a component used to form a wheel name according to PEP 491"""
+    return re.sub(r"[^\w\d.]+", "_", component)
+
+
+def _version(version: str) -> str:
+    """Convert an arbitrary string to a version string."""
+    v = version.replace(' ', '.')
+    try:
+        return str(packaging.version.Version(v)).replace("-", "_")
+    except packaging.version.InvalidVersion:
+        msg = f"""!!\n\n
+        ###################
+        # Invalid version #
+        ###################
+        {version!r} is not valid according to PEP 440.\n
+        Please make sure specify a valid version for your package.
+        Also note that future releases of setuptools may halt the build process
+        if an invalid version is given.
+        \n\n!!
+        """
+        warnings.warn(cleandoc(msg))
+        return _safe(v).strip("_")
diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py
--- a/setuptools/command/egg_info.py
+++ b/setuptools/command/egg_info.py
@@ -136,11 +136,21 @@ def _maybe_tag(self, version):
         in which case the version string already contains all tags.
         """
         return (
-            version if self.vtags and version.endswith(self.vtags)
+            version if self.vtags and self._already_tagged(version)
             else version + self.vtags
         )
 
-    def tags(self):
+    def _already_tagged(self, version: str) -> bool:
+        # Depending on their format, tags may change with version normalization.
+        # So in addition the regular tags, we have to search for the normalized ones.
+        return version.endswith(self.vtags) or version.endswith(self._safe_tags())
+
+    def _safe_tags(self) -> str:
+        # To implement this we can rely on `safe_version` pretending to be version 0
+        # followed by tags. Then we simply discard the starting 0 (fake version number)
+        return safe_version(f"0{self.vtags}")[1:]
+
+    def tags(self) -> str:
         version = ''
         if self.tag_build:
             version += self.tag_build


Test patch:
diff --git a/setuptools/tests/test_dist_info.py b/setuptools/tests/test_dist_info.py
--- a/setuptools/tests/test_dist_info.py
+++ b/setuptools/tests/test_dist_info.py
@@ -1,12 +1,21 @@
 """Test .dist-info style distributions.
 """
+import pathlib
+import re
+import subprocess
+import sys
+from functools import partial
 
 import pytest
 
 import pkg_resources
+from setuptools.archive_util import unpack_archive
 from .textwrap import DALS
 
 
+read = partial(pathlib.Path.read_text, encoding="utf-8")
+
+
 class TestDistInfo:
 
     metadata_base = DALS("""
@@ -72,3 +81,78 @@ def test_conditional_dependencies(self, metadata):
                 pkg_resources.Requirement.parse('quux>=1.1;extra=="baz"'),
             ]
             assert d.extras == ['baz']
+
+    def test_invalid_version(self, tmp_path):
+        config = "[metadata]\nname=proj\nversion=42\n[egg_info]\ntag_build=invalid!!!\n"
+        (tmp_path / "setup.cfg").write_text(config, encoding="utf-8")
+        msg = re.compile("invalid version", re.M | re.I)
+        output = run_command("dist_info", cwd=tmp_path)
+        assert msg.search(output)
+        dist_info = next(tmp_path.glob("*.dist-info"))
+        assert dist_info.name.startswith("proj-42")
+
+
+class TestWheelCompatibility:
+    """Make sure the .dist-info directory produced with the ``dist_info`` command
+    is the same as the one produced by ``bdist_wheel``.
+    """
+    SETUPCFG = DALS("""
+    [metadata]
+    name = {name}
+    version = {version}
+
+    [options]
+    install_requires = foo>=12; sys_platform != "linux"
+
+    [options.extras_require]
+    test = pytest
+
+    [options.entry_points]
+    console_scripts =
+        executable-name = my_package.module:function
+    discover =
+        myproj = my_package.other_module:function
+    """)
+
+    EGG_INFO_OPTS = [
+        # Related: #3088 #2872
+        ("", ""),
+        (".post", "[egg_info]\ntag_build = post\n"),
+        (".post", "[egg_info]\ntag_build = .post\n"),
+        (".post", "[egg_info]\ntag_build = post\ntag_date = 1\n"),
+        (".dev", "[egg_info]\ntag_build = .dev\n"),
+        (".dev", "[egg_info]\ntag_build = .dev\ntag_date = 1\n"),
+        ("a1", "[egg_info]\ntag_build = .a1\n"),
+        ("+local", "[egg_info]\ntag_build = +local\n"),
+    ]
+
+    @pytest.mark.parametrize("name", "my-proj my_proj my.proj My.Proj".split())
+    @pytest.mark.parametrize("version", ["0.42.13"])
+    @pytest.mark.parametrize("suffix, cfg", EGG_INFO_OPTS)
+    def test_dist_info_is_the_same_as_in_wheel(
+        self, name, version, tmp_path, suffix, cfg
+    ):
+        config = self.SETUPCFG.format(name=name, version=version) + cfg
+
+        for i in "dir_wheel", "dir_dist":
+            (tmp_path / i).mkdir()
+            (tmp_path / i / "setup.cfg").write_text(config, encoding="utf-8")
+
+        run_command("bdist_wheel", cwd=tmp_path / "dir_wheel")
+        wheel = next(tmp_path.glob("dir_wheel/dist/*.whl"))
+        unpack_archive(wheel, tmp_path / "unpack")
+        wheel_dist_info = next(tmp_path.glob("unpack/*.dist-info"))
+
+        run_command("dist_info", cwd=tmp_path / "dir_dist")
+        dist_info = next(tmp_path.glob("dir_dist/*.dist-info"))
+
+        assert dist_info.name == wheel_dist_info.name
+        assert dist_info.name.startswith(f"{name.replace('-', '_')}-{version}{suffix}")
+        for file in "METADATA", "entry_points.txt":
+            assert read(dist_info / file) == read(wheel_dist_info / file)
+
+
+def run_command(*cmd, **kwargs):
+    opts = {"stderr": subprocess.STDOUT, "text": True, **kwargs}
+    cmd = [sys.executable, "-c", "__import__('setuptools').setup()", *cmd]
+    return subprocess.check_output(cmd, **opts)