Repository: pypa/setuptools (python)

Issue:
build meta wheel does not respect metadata_directory per PEP-517
From https://www.python.org/dev/peps/pep-0517/#build-wheel:

> If the build frontend has previously called prepare_metadata_for_build_wheel and depends on the wheel resulting from this call to have metadata matching this earlier call, then it should provide the path to the created .dist-info directory as the metadata_directory argument.

https://github.com/pypa/setuptools/blob/master/setuptools/build_meta.py#L208 though ignores the passed in ``metadata_directory`` argument and goes to regenerate this information in the root of the project.

```python
    def build_wheel(self, wheel_directory, config_settings=None,
                    metadata_directory=None):
        return self._build_with_temp_dir(['bdist_wheel'], '.whl',
                                         wheel_directory, config_settings)
```

Pull request description:
Support the metadata directory (as per PEP-517) for build_wheel
## Summary of changes

Allow ``dist-info-dir`` to be passed to `bdist_wheel`.
More importantly, fixes the `build_wheel` PEP-517 interface to treat the given metadata as per the spec. 

With this change, I was able to verify that I could:

```
python setup.py dist_info
touch setuptools-74.1.2.post20240913.dist-info/FOO
python setup.py bdist_wheel --dist-info-dir setuptools-74.1.2.post20240913.dist-info/
```

And the resulting wheel contained my desired metadata file. My personal goal is to support this behaviour in PEP-517 build backends, and to write a setuptools build backend wrapper which injects such metadata (I need to do something like `setuptools-ext` does). Presently, the functionality does not exist, you end up having to post-process the built wheel (hence the [complexity that exists in `setuptools-ext`](https://github.com/wimglenn/setuptools-ext/blob/ee8f5108b21ef526dc05d8f29fffee8d5cd631ff/setuptools_ext.py#L211)).

Honestly, despite having written many Command subclasses over many years, I am very unfamiliar with the intricacies of `distutils.Command` and `setuptools` in general, and may have implemented a solution which is not quite right due to a lack of that knowledge.

Closes #1825. Follows a prototype I did in https://github.com/pypa/wheel/issues/611.

### Pull Request Checklist
- [x] Changes have tests
- [x] News fragment added in [`newsfragments/`].
  _(See [documentation][PR docs] for details)_


[`newsfragments/`]: https://github.com/pypa/setuptools/tree/master/newsfragments
[PR docs]:
https://setuptools.pypa.io/en/latest/development/developer-guide.html#making-a-pull-request


Patch:
diff --git a/newsfragments/1825.bugfix.rst b/newsfragments/1825.bugfix.rst
new file mode 100644
index 000000000..ff55d1872
--- /dev/null
+++ b/newsfragments/1825.bugfix.rst
@@ -0,0 +1,1 @@
+Re-use pre-existing ``.dist-info`` dir when creating wheels via the build backend APIs (PEP 517) and the ``metadata_directory`` argument is passed -- by :user:`pelson`.
diff --git a/setuptools/build_meta.py b/setuptools/build_meta.py
index a6b85afc4..ecf434bbf 100644
--- a/setuptools/build_meta.py
+++ b/setuptools/build_meta.py
@@ -417,9 +417,12 @@ class _BuildMetaBackend(_ConfigSettingsTranslator):
         config_settings: _ConfigSettings = None,
         metadata_directory: StrPath | None = None,
     ):
+        cmd = ['bdist_wheel']
+        if metadata_directory:
+            cmd.extend(['--dist-info-dir', metadata_directory])
         with suppress_known_deprecation():
             return self._build_with_temp_dir(
-                ['bdist_wheel'],
+                cmd,
                 '.whl',
                 wheel_directory,
                 config_settings,
diff --git a/setuptools/command/bdist_wheel.py b/setuptools/command/bdist_wheel.py
index fa97976fe..aeade98f6 100644
--- a/setuptools/command/bdist_wheel.py
+++ b/setuptools/command/bdist_wheel.py
@@ -231,6 +231,13 @@ class bdist_wheel(Command):
             None,
             "Python tag (cp32|cp33|cpNN) for abi3 wheel tag [default: false]",
         ),
+        (
+            "dist-info-dir=",
+            None,
+            "directory where a pre-generated dist-info can be found (e.g. as a "
+            "result of calling the PEP517 'prepare_metadata_for_build_wheel' "
+            "method)",
+        ),
     ]
 
     boolean_options = ["keep-temp", "skip-build", "relative", "universal"]
@@ -243,6 +250,7 @@ class bdist_wheel(Command):
         self.format = "zip"
         self.keep_temp = False
         self.dist_dir: str | None = None
+        self.dist_info_dir = None
         self.egginfo_dir: str | None = None
         self.root_is_pure: bool | None = None
         self.skip_build = False
@@ -261,8 +269,9 @@ class bdist_wheel(Command):
             bdist_base = self.get_finalized_command("bdist").bdist_base
             self.bdist_dir = os.path.join(bdist_base, "wheel")
 
-        egg_info = cast(egg_info_cls, self.distribution.get_command_obj("egg_info"))
-        egg_info.ensure_finalized()  # needed for correct `wheel_dist_name`
+        if self.dist_info_dir is None:
+            egg_info = cast(egg_info_cls, self.distribution.get_command_obj("egg_info"))
+            egg_info.ensure_finalized()  # needed for correct `wheel_dist_name`
 
         self.data_dir = self.wheel_dist_name + ".data"
         self.plat_name_supplied = bool(self.plat_name)
@@ -447,7 +456,16 @@ class bdist_wheel(Command):
             f"{safer_version(self.distribution.get_version())}.dist-info"
         )
         distinfo_dir = os.path.join(self.bdist_dir, distinfo_dirname)
-        self.egg2dist(self.egginfo_dir, distinfo_dir)
+        if self.dist_info_dir:
+            # Use the given dist-info directly.
+            log.debug(f"reusing {self.dist_info_dir}")
+            shutil.copytree(self.dist_info_dir, distinfo_dir)
+            # Egg info is still generated, so remove it now to avoid it getting
+            # copied into the wheel.
+            shutil.rmtree(self.egginfo_dir)
+        else:
+            # Convert the generated egg-info into dist-info.
+            self.egg2dist(self.egginfo_dir, distinfo_dir)
 
         self.write_wheelfile(distinfo_dir)
 


Test patch:
diff --git a/setuptools/tests/test_bdist_wheel.py b/setuptools/tests/test_bdist_wheel.py
index 8b64e90f7..47200d0a2 100644
--- a/setuptools/tests/test_bdist_wheel.py
+++ b/setuptools/tests/test_bdist_wheel.py
@@ -619,3 +619,28 @@ def test_no_ctypes(monkeypatch) -> None:
     monkeypatch.delitem(sys.modules, "setuptools.command.bdist_wheel")
 
     import setuptools.command.bdist_wheel  # noqa: F401
+
+
+def test_dist_info_provided(dummy_dist, monkeypatch, tmp_path):
+    monkeypatch.chdir(dummy_dist)
+    distinfo = tmp_path / "dummy_dist.dist-info"
+
+    distinfo.mkdir()
+    (distinfo / "METADATA").write_text("name: helloworld", encoding="utf-8")
+
+    # We don't control the metadata. According to PEP-517, "The hook MAY also
+    # create other files inside this directory, and a build frontend MUST
+    # preserve".
+    (distinfo / "FOO").write_text("bar", encoding="utf-8")
+
+    bdist_wheel_cmd(bdist_dir=str(tmp_path), dist_info_dir=str(distinfo)).run()
+    expected = {
+        "dummy_dist-1.0.dist-info/FOO",
+        "dummy_dist-1.0.dist-info/RECORD",
+    }
+    with ZipFile("dist/dummy_dist-1.0-py3-none-any.whl") as wf:
+        files_found = set(wf.namelist())
+    # Check that all expected files are there.
+    assert expected - files_found == set()
+    # Make sure there is no accidental egg-info bleeding into the wheel.
+    assert not [path for path in files_found if 'egg-info' in str(path)]