Repository: pdm-project/pdm

Issue:
pdm 2.13.x + pdm-wheel: [TypeError]: expected str, bytes or os.PathLike object, not CachedPackage
- [x] I have searched the issue tracker and believe that this is not a duplicate.

**Make sure you run commands with `-v` flag before pasting the output.**

## Steps to reproduce

```sh
set -e
pdm=$(pwd)
pyver=3.12
d=/tmp/pdm-issue
rm -rf $d
mkdir -p $d
python${pyver} -m venv $d/venv
$d/venv/bin/python -m pip install "$pdm"
$d/venv/bin/pdm self add pdm-wheel
mkdir $d/project
cd $d/project
$d/venv/bin/pdm init --non-interactive --python ${pyver}
$d/venv/bin/pdm add setproctitle psycopg gunicorn rq
$d/venv/bin/pdm wheel -v --prod
```


<!--Describe the minimized example of how to reproduce the bug-->

## Actual behavior

`pdm wheel --prod` fails:

```
$d/venv/bin/pdm wheel -v --prod
Checking project file...
Checking lockfile...
The lockfile does not contain static file URLs. Exporting wheel may be longer than expected, and 
make calls to indexes.
Use `pdm lock --refresh --static-urls` to include static file URLs in the lockfile.
Resolving wheel candidates for this platform...
The exported wheels are no longer cross-platform. Using it them other platforms may cause unexpected
result.
STATUS: Resolving packages from lockfile...
STATUS: Fetching hashes for resolved packages...
Creating target directory: /tmp/pdm-issue/project/wheels
Traceback (most recent call last):
  File "/tmp/pdm-issue/venv/bin/pdm", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/tmp/pdm-issue/venv/lib/python3.12/site-packages/pdm/core.py", line 341, in main
    return core.main(args or sys.argv[1:])
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/pdm-issue/venv/lib/python3.12/site-packages/pdm/core.py", line 259, in main
    raise cast(Exception, err).with_traceback(traceback) from None
  File "/tmp/pdm-issue/venv/lib/python3.12/site-packages/pdm/core.py", line 254, in main
    self.handle(project, options)
  File "/tmp/pdm-issue/venv/lib/python3.12/site-packages/pdm/core.py", line 209, in handle
    command.handle(project, options)
  File "/tmp/pdm-issue/venv/lib/python3.12/site-packages/pdm_wheel/wheel.py", line 116, in handle
    rel_path = shutil.copy(path, wheel_dir)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/shutil.py", line 434, in copy
    dst = os.path.join(dst, os.path.basename(src))
                            ^^^^^^^^^^^^^^^^^^^^^
  File "<frozen posixpath>", line 171, in basename
TypeError: expected str, bytes or os.PathLike object, not CachedPackage
```

<!--A clear and concise description the result of the above steps-->

## Expected behavior

Prior to pdm 2.13 this operation succeeds and creates wheels in the `wheels` directory.  `git bisect` points to f7f87bb as the offending commit.  I'm not sure, but I don't think the actual packages in the project matter.

<!--A clear and concise description of what you expected to happen.-->

## Environment Information

- (Gentoo) Linux
- Python 3.12


# Paste the output of `pdm info && pdm info --env` below:

```
PDM version:
  2.13.1
Python Interpreter:
  /usr/bin/python3.12 (3.12)
Project Root:
  /tmp/pdm-issue/project
Local Packages:
  /tmp/pdm-issue/project/__pypackages__/3.12

{
  "implementation_name": "cpython",
  "implementation_version": "3.12.2",
  "os_name": "posix",
  "platform_machine": "x86_64",
  "platform_release": "6.8.2",
  "platform_system": "Linux",
  "platform_version": "#1 SMP Wed Mar 27 07:03:46 AM CDT 2024",
  "python_full_version": "3.12.2",
  "platform_python_implementation": "CPython",
  "python_version": "3.12",
  "sys_platform": "linux"
}
```


Discussion:
I think pdm-wheel is responsible for updating to be compatible with 2.13
I changed my mind, I am going to backport the old `build` function to not break downstream. Thanks.



Patch:
diff --git a/news/2747.bugfix.md b/news/2747.bugfix.md
new file mode 100644
index 0000000000..094d0b416b
--- /dev/null
+++ b/news/2747.bugfix.md
@@ -0,0 +1,1 @@
+Add back `PreparedCandidate.build()` for backward-compatibility.
diff --git a/src/pdm/installers/manager.py b/src/pdm/installers/manager.py
index 9adf8ac294..fcbe43fdbb 100644
--- a/src/pdm/installers/manager.py
+++ b/src/pdm/installers/manager.py
@@ -26,7 +26,9 @@ def __init__(self, environment: BaseEnvironment, *, use_install_cache: bool = Fa
     def install(self, candidate: Candidate) -> Distribution:
         """Install a candidate into the environment, return the distribution"""
         prepared = candidate.prepare(self.environment)
-        dist_info = install_package(prepared.build(), self.environment, prepared.direct_url(), self.use_install_cache)
+        dist_info = install_package(
+            prepared.get_cached_package(), self.environment, prepared.direct_url(), self.use_install_cache
+        )
         return Distribution.at(dist_info)
 
     def get_paths_to_remove(self, dist: Distribution) -> BaseRemovePaths:
diff --git a/src/pdm/models/cached_package.py b/src/pdm/models/cached_package.py
index 9c7d875b16..fc2bbc7a07 100644
--- a/src/pdm/models/cached_package.py
+++ b/src/pdm/models/cached_package.py
@@ -22,8 +22,9 @@ class CachedPackage:
     *Only wheel installations will be cached*
     """
 
-    def __init__(self, path: str | Path) -> None:
+    def __init__(self, path: str | Path, original_wheel: Path | None = None) -> None:
         self.path = Path(os.path.normcase(os.path.expanduser(path))).resolve()
+        self.original_wheel = original_wheel
         self._referrers: set[str] | None = None
 
     def lock(self) -> ContextManager[Any]:
diff --git a/src/pdm/models/caches.py b/src/pdm/models/caches.py
index 9f3842627d..f518ec5774 100644
--- a/src/pdm/models/caches.py
+++ b/src/pdm/models/caches.py
@@ -285,7 +285,7 @@ def cache_wheel(self, wheel: Path, checksum: str | None = None) -> CachedPackage
             checksum = get_file_hash(wheel)
         parts = (checksum[:2],)  # shard by the first two chars of the checksum
         dest = self.root.joinpath(*parts, f"{wheel.name}.cache")
-        pkg = CachedPackage(dest)
+        pkg = CachedPackage(dest, original_wheel=wheel)
         if dest.exists():
             return pkg
         dest.mkdir(parents=True, exist_ok=True)
diff --git a/src/pdm/models/candidates.py b/src/pdm/models/candidates.py
index d55a5e9bad..4d222c86c3 100644
--- a/src/pdm/models/candidates.py
+++ b/src/pdm/models/candidates.py
@@ -399,16 +399,29 @@ def direct_url(self) -> dict[str, Any] | None:
         else:
             return None
 
-    def build(self) -> CachedPackage:
-        """Call PEP 517 build hook to build the candidate into a wheel"""
-        self.obtain(allow_all=False)
+    def get_cached_package(self) -> CachedPackage:
+        """Call PEP 517 build hook to build the candidate into a cached package"""
+        self._obtain(allow_all=False)
         if self._cached:
             return self._cached
+        wheel = self.build()
+        checksum_path = Path(f"{wheel}.sha256")
+        if checksum_path.exists():
+            checksum = checksum_path.read_text().strip()
+        else:
+            checksum = None
+        self._cached = self.environment.project.package_cache.cache_wheel(wheel, checksum)
+        return self._cached
+
+    def build(self) -> Path:
+        """Call PEP 517 build hook to build the candidate into a wheel"""
+        self._obtain(allow_all=False, prefer_wheel=True)
+        if self._cached and self._cached.original_wheel:
+            return self._cached.original_wheel
         if not self.req.editable:
             cached, checksum = self._get_build_cache()
             if cached:
-                self._cached = self.environment.project.package_cache.cache_wheel(cached, checksum)
-                return self._cached
+                return cached
         assert self._source_dir, "Source directory isn't ready yet"
         builder_cls = EditableBuilder if self.req.editable else WheelBuilder
         builder = builder_cls(str(self._unpacked_dir), self.environment)
@@ -421,17 +434,20 @@ def build(self) -> CachedPackage:
         with open(f"{wheel}.sha256", "w") as f:
             f.write(checksum)
         self.reporter.report_build_end(self.link.filename)  # type: ignore[union-attr]
-        return self.environment.project.package_cache.cache_wheel(wheel, checksum)
+        return wheel
 
-    def obtain(self, allow_all: bool = False, unpack: bool = True) -> None:
+    def _obtain(self, allow_all: bool = False, unpack: bool = True, prefer_wheel: bool = False) -> None:
         """Fetch the link of the candidate and unpack to local if necessary.
 
         :param allow_all: If true, don't validate the wheel tag nor hashes
         :param unpack: Whether to download and unpack the link if it's not local
+        :param prefer_wheel: Prefer wheel over cached package
         """
         if self._cached and self._wheel_compatible(self._cached.path.stem, allow_all):
-            return
-        elif self._source_dir and self._source_dir.exists():
+            if not prefer_wheel or self._cached.original_wheel:
+                return
+
+        if self._source_dir and self._source_dir.exists():
             return
 
         sources = filtered_sources(self.environment.project.sources, self.req.key)
@@ -456,7 +472,8 @@ def obtain(self, allow_all: bool = False, unpack: bool = True) -> None:
         package = self.environment.project.package_cache.match_link(self.link)
         if package is not None:
             self._cached = package
-            return
+            if not prefer_wheel:
+                return
         # If not, find if there is any build cache for the candidate
         if allow_all and not self.req.editable:
             cached, checksum = self._get_build_cache()
@@ -495,7 +512,7 @@ def _unpack(self, validate_hashes: bool = False) -> None:
             self._unpacked_dir = result
 
     def prepare_metadata(self, force_build: bool = False) -> im.Distribution:
-        self.obtain(allow_all=True, unpack=False)
+        self._obtain(allow_all=True, unpack=False)
 
         if self._cached:
             return self._get_metadata_from_cached(self._cached)


Test patch:
diff --git a/tests/models/test_candidates.py b/tests/models/test_candidates.py
index 9dcdee6196..9589395188 100644
--- a/tests/models/test_candidates.py
+++ b/tests/models/test_candidates.py
@@ -209,22 +209,22 @@ def test_sdist_candidate_with_wheel_cache(project, mocker):
 
     prepared._cached = None
     builder = mocker.patch("pdm.builders.WheelBuilder.build")
-    package = prepared.build()
+    wheel = prepared.build()
     builder.assert_not_called()
-    assert package.path.stem == built_path.name
+    assert wheel.name == built_path.name
 
 
 @pytest.mark.usefixtures("vcs", "local_finder")
 def test_cache_vcs_immutable_revision(project):
     req = parse_requirement("git+https://github.com/test-root/demo.git@master#egg=demo")
     candidate = Candidate(req)
-    candidate.prepare(project.environment).build()
+    candidate.prepare(project.environment).get_cached_package()
     assert not is_path_relative_to(candidate.prepared._get_build_cache()[0], project.cache_dir)
     assert candidate.get_revision() == "1234567890abcdef"
 
     req = parse_requirement("git+https://github.com/test-root/demo.git@1234567890abcdef#egg=demo")
     candidate = Candidate(req)
-    candidate.prepare(project.environment).build()
+    candidate.prepare(project.environment).get_cached_package()
     assert is_path_relative_to(candidate.prepared._get_build_cache()[0], project.cache_dir)
     assert candidate.get_revision() == "1234567890abcdef"
 
@@ -238,7 +238,7 @@ def test_cache_vcs_immutable_revision(project):
 def test_cache_egg_info_sdist(project):
     req = parse_requirement("demo @ http://fixtures.test/artifacts/demo-0.0.1.tar.gz")
     candidate = Candidate(req)
-    candidate.prepare(project.environment).build()
+    candidate.prepare(project.environment).get_cached_package()
     assert is_path_relative_to(candidate.prepared._get_build_cache()[0], project.cache_dir)
 
 
@@ -251,10 +251,10 @@ def test_invalidate_incompatible_wheel_link(project):
         version="0.0.1",
         link=Link("http://fixtures.test/artifacts/demo-0.0.1-cp36-cp36m-win_amd64.whl"),
     ).prepare(project.environment)
-    prepared.obtain(True)
+    prepared._obtain(True)
     assert prepared._cached.path.stem == prepared.link.filename == "demo-0.0.1-cp36-cp36m-win_amd64.whl"
 
-    prepared.obtain(False)
+    prepared._obtain(False)
     assert prepared._cached.path.stem == prepared.link.filename == "demo-0.0.1-py2.py3-none-any.whl"