Repository: pdm-project/pdm

Issue:
Consider building wheel from sdist to ensure a proper sdist is created.
## Is your feature/enhancement proposal related to a problem? Please describe.

<!--A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]-->
I have some Cython codes (`.pyx` files) in my project, and I use setuptools backend (the only backend supports building C extensions AFAIK) to compile them.

For source installation, those `.pyx` files should be included in sdist, but setuptools doesn't include them by default.

The problem is, currently `pdm build` will always succeed to build the sdist and the wheel even if those `.pyx` files are not included in the sdist, and the created sdist cannot be installed due to the lack of `.pyx` files.

## Describe the solution you'd like

<!--A clear and concise description of what you ant to happen.-->

I suggest we first build the sdist, and then build the wheel from that sdist. This is the same approach as [build](https://pypi.org/project/build/).

Discussion:

Patch:
diff --git a/news/2843.bugfix.md b/news/2843.bugfix.md
new file mode 100644
index 00000000..e0650a26
--- /dev/null
+++ b/news/2843.bugfix.md
@@ -0,0 +1,1 @@
+Build wheel from sdist if available, to make sure sdist is built properly. This behavior is consistent with [pypa/build](https://pypi.org/project/build).
diff --git a/news/2908.bugfix.md b/news/2908.bugfix.md
new file mode 100644
index 00000000..5f1da2d6
--- /dev/null
+++ b/news/2908.bugfix.md
@@ -0,0 +1,1 @@
+Fix a bug that VCS dependencies and `--self` don't work in the exported requirements.txt with hashes.
diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py
index df4d2ce1..76db5728 100644
--- a/src/pdm/cli/commands/add.py
+++ b/src/pdm/cli/commands/add.py
@@ -155,8 +155,9 @@ class Command(BaseCommand):
         reqs = [
             r for g, deps in all_dependencies.items() if lock_groups is None or g in lock_groups for r in deps.values()
         ]
-        # pre-write the dependencies to the pyproject.toml to make them recognized by the resolver
-        project.add_dependencies(requirements, group, selection.dev or False, write=False)
+        # pre-write the named dependencies to the pyproject.toml to make them recognized by the resolver
+        named_req = {k: r for k, r in requirements.items() if r.is_named}
+        project.add_dependencies(named_req, group, selection.dev or False, write=False)
         with hooks.skipping("post_lock"):
             resolved = do_lock(
                 project,
diff --git a/src/pdm/cli/commands/build.py b/src/pdm/cli/commands/build.py
index 84d02c58..f4203c4c 100644
--- a/src/pdm/cli/commands/build.py
+++ b/src/pdm/cli/commands/build.py
@@ -3,6 +3,8 @@ from __future__ import annotations
 import argparse
 import os
 import shutil
+import tarfile
+import tempfile
 from typing import Mapping
 
 from pdm.cli.commands.base import BaseCommand
@@ -56,15 +58,28 @@ class Command(BaseCommand):
         artifacts: list[str] = []
         with project.core.ui.logging("build"):
             if sdist:
-                project.core.ui.echo("Building sdist...")
-                loc = SdistBuilder(project.root, project.environment).build(dest)
-                project.core.ui.echo(f"Built sdist at {loc}")
-                artifacts.append(loc)
+                project.core.ui.echo("[info]Building sdist...")
+                sdist_file = SdistBuilder(project.root, project.environment).build(dest)
+                project.core.ui.echo(f"[info]Built sdist at {sdist_file}")
+                artifacts.append(sdist_file)
             if wheel:
-                project.core.ui.echo("Building wheel...")
-                loc = WheelBuilder(project.root, project.environment).build(dest)
-                project.core.ui.echo(f"Built wheel at {loc}")
-                artifacts.append(loc)
+                if sdist:
+                    project.core.ui.echo("[info]Building wheel from sdist...")
+                    sdist_out = tempfile.mkdtemp(prefix="pdm-build-via-sdist-")
+                    try:
+                        with tarfile.open(sdist_file, "r:gz") as tf:
+                            tf.extractall(sdist_out)
+                            sdist_name = os.path.basename(sdist_file)[: -len(".tar.gz")]
+                            whl = WheelBuilder(os.path.join(sdist_out, sdist_name), project.environment).build(dest)
+                            project.core.ui.echo(f"[info]Built wheel at {whl}")
+                            artifacts.append(whl)
+                    finally:
+                        shutil.rmtree(sdist_out, ignore_errors=True)
+                else:
+                    project.core.ui.echo("[info]Building wheel...")
+                    whl = WheelBuilder(project.root, project.environment).build(dest)
+                    project.core.ui.echo(f"[info]Built wheel at {whl}")
+                    artifacts.append(whl)
         hooks.try_emit("post_build", artifacts=artifacts, config_settings=config_settings)
 
     def add_arguments(self, parser: argparse.ArgumentParser) -> None:
diff --git a/src/pdm/formats/requirements.py b/src/pdm/formats/requirements.py
index 0e40c279..f4ff02b9 100644
--- a/src/pdm/formats/requirements.py
+++ b/src/pdm/formats/requirements.py
@@ -189,6 +189,8 @@ def export(
     for candidate in sorted(candidates, key=lambda x: x.identify()):  # type: ignore[attr-defined]
         if isinstance(candidate, Candidate):
             req = candidate.req.as_pinned_version(candidate.version)
+            if options.hashes and not candidate.hashes:
+                raise PdmUsageError(f"Hash is not available for '{req}', please export with `--no-hashes` option.")
         else:
             assert isinstance(candidate, Requirement)
             req = candidate
@@ -203,6 +205,8 @@ def export(
         lines.append("\n")
     if (options.self or options.editable_self) and not project.is_distribution:
         raise PdmUsageError("Cannot export the project itself in a non-library project.")
+    if options.hashes and (options.self or options.editable_self):
+        raise PdmUsageError("Hash is not available for `--self/--editable-self`. Please export with `--no-hashes`.")
     if options.self:
         lines.append(".  # this package\n")
     elif options.editable_self:


Test patch:
diff --git a/tests/cli/test_build.py b/tests/cli/test_build.py
index 0b37909a..6e49d25d 100644
--- a/tests/cli/test_build.py
+++ b/tests/cli/test_build.py
@@ -54,12 +54,15 @@ def test_build_single_module(fixture_project):
     ]:
         assert f"demo_module-0.1.0/{name}" in tar_names
 
-    zip_names = get_wheel_names(project.root / "dist/demo_module-0.1.0-py3-none-any.whl")
-    for name in ["foo_module.py", "bar_module.py"]:
-        assert name in zip_names
-
-    for name in ("pyproject.toml", "LICENSE"):
-        assert name not in zip_names
+    for i in range(2):
+        if i == 1:
+            Command.do_build(project, sdist=False)
+        zip_names = get_wheel_names(project.root / "dist/demo_module-0.1.0-py3-none-any.whl")
+        for name in ["foo_module.py", "bar_module.py"]:
+            assert name in zip_names
+
+        for name in ("pyproject.toml", "LICENSE"):
+            assert name not in zip_names
 
 
 def test_build_single_module_with_readme(fixture_project):
diff --git a/tests/cli/test_others.py b/tests/cli/test_others.py
index 90541ce0..7a5123dc 100644
--- a/tests/cli/test_others.py
+++ b/tests/cli/test_others.py
@@ -176,10 +176,16 @@ def test_export_to_requirements_txt(pdm, fixture_project):
     assert result.output.strip() == requirements_txt.read_text().strip()
 
     result = pdm(["export", "--self"], obj=project)
+    assert result.exit_code == 1
+
+    result = pdm(["export", "--editable-self"], obj=project)
+    assert result.exit_code == 1
+
+    result = pdm(["export", "--no-hashes", "--self"], obj=project)
     assert result.exit_code == 0
     assert ".  # this package\n" in result.output.strip()
 
-    result = pdm(["export", "--editable-self"], obj=project)
+    result = pdm(["export", "--no-hashes", "--editable-self"], obj=project)
     assert result.exit_code == 0
     assert "-e .  # this package\n" in result.output.strip()
 
@@ -227,8 +233,19 @@ def test_show_update_hint(pdm, project, monkeypatch):
 @pytest.mark.usefixtures("repository")
 def test_export_with_platform_markers(pdm, project):
     pdm(["add", "--no-sync", 'urllib3; sys_platform == "fake"'], obj=project, strict=True)
-    result = pdm(["export"], obj=project, strict=True)
+    result = pdm(["export", "--no-hashes"], obj=project, strict=True)
     assert 'urllib3==1.22; sys_platform == "fake"' in result.output.splitlines()
 
-    result = pdm(["export", "--no-markers"], obj=project, strict=True)
+    result = pdm(["export", "--no-markers", "--no-hashes"], obj=project, strict=True)
     assert not any("urllib3" in line for line in result.output.splitlines())
+
+
+@pytest.mark.usefixtures("repository", "vcs")
+def test_export_with_vcs_deps(pdm, project):
+    pdm(["add", "--no-sync", "git+https://github.com/test-root/demo.git"], obj=project, strict=True)
+    result = pdm(["export"], obj=project)
+    assert result.exit_code != 0
+
+    result = pdm(["export", "--no-hashes"], obj=project)
+    assert result.exit_code == 0
+    assert "demo @ git+https://github.com/test-root/demo.git@1234567890abcdef" in result.output.splitlines()
diff --git a/tests/test_formats.py b/tests/test_formats.py
index 52831118..874b2ab9 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -175,12 +175,12 @@ def test_import_requirements_with_group(project):
 
 
 def test_export_requirements_with_self(project):
-    result = requirements.export(project, [], ns(self=True))
+    result = requirements.export(project, [], ns(self=True, hashes=False))
     assert result.strip().splitlines()[-1] == ".  # this package"
 
 
 def test_export_requirements_with_editable_self(project):
-    result = requirements.export(project, [], ns(editable_self=True))
+    result = requirements.export(project, [], ns(editable_self=True, hashes=False))
     assert result.strip().splitlines()[-1] == "-e .  # this package"