Repository: pdm-project/pdm
Pull request #3436 (2025-03-29 16:03:00)

feat: use pyapp to build dist in release 
## Pull Request Checklist

- [x] A news fragment is added in `news/` describing what is new.
- [x] Test cases added for changed code.

## Describe what you have changed in this PR.

- close https://github.com/pdm-project/pdm/issues/3429

cc @Zheaoli 

I have tested the CI in https://github.com/kemingy/pdm/actions/runs/14147198843 & https://github.com/kemingy/pdm/releases/tag/v0.1.0-alpha.1

kemingy: If I understand it correctly, it's impossible to build a fully offline bootstrap package. Even if I specify the dependencies, it still needs to download them during the bootstrap step.

- refer to https://github.com/ofek/pyapp/issues/117

Patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 29780cb1..2753f295 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -5,6 +5,11 @@ on:
     tags:
       - "*"
 
+defaults:
+  run:
+    # make sure to work on Windows
+    shell: bash
+
 jobs:
   release-pypi:
     name: release-pypi
@@ -34,6 +39,14 @@ jobs:
         run: |
           pipx run build
 
+      - name: Upload artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: pdm-wheel
+          path: dist/*.whl
+          if-no-files-found: error
+          retention-days: 15
+
       - name: Test Build
         run: |
           python -m pip install "pdm[locked] @ file://$(ls ${GITHUB_WORKSPACE}/dist/*.whl)"
@@ -65,3 +78,77 @@ jobs:
           repo: frostming/scoop-frostming
           token: ${{ secrets.G_T }}
           ref: master
+
+  binary:
+    needs: release-pypi
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ["ubuntu-22.04", "ubuntu-22.04-arm", "windows-2022", "macos-13", "macos-14"]
+
+    env:
+      PYAPP_REPO: pyapp
+      PYAPP_VERSION: "0.27.0"
+      PYAPP_PROJECT_NAME: pdm
+      PYAPP_PROJECT_VERSION: ${{ github.ref_name }}
+      PYAPP_SELF_COMMAND: app  # since `self` has been taken in `pdm`
+      PYAPP_DISTRIBUTION_EMBED: true
+      PYAPP_PROJECT_FEATURES: locked
+      SCCACHE_GHA_ENABLED: "true"
+      RUSTC_WRAPPER: "sccache"
+      SOURCE_FILE: ${{ matrix.os != 'windows-2022' && 'pyapp' || 'pyapp.exe' }}
+      TARGET_FILE: ${{ matrix.os != 'windows-2022' && 'pdm' || 'pdm.exe' }}
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Fetch PyApp
+        run: >-
+          mkdir $PYAPP_REPO && curl -L
+          https://github.com/ofek/pyapp/releases/download/v$PYAPP_VERSION/source.tar.gz
+          |
+          tar --strip-components=1 -xzf - -C $PYAPP_REPO
+
+      - name: Setup Rust
+        uses: dtolnay/rust-toolchain@stable
+
+      - name: Run sccache-cache
+        uses: mozilla-actions/sccache-action@v0.0.9
+
+      - name: Download artifacts
+        uses: actions/download-artifact@v4
+        with:
+          name: pdm-wheel
+          path: dist
+
+      - name: Configure embedded wheel
+        run: |
+          cd dist
+          wheel="$(echo *.whl)"
+          mv $wheel ../$PYAPP_REPO
+          echo "PYAPP_PROJECT_PATH=$wheel" >> $GITHUB_ENV
+          echo "TARGET_TRIPLE=$(rustc --version --verbose | grep "host" | awk '{print $2}')" >> $GITHUB_ENV
+
+      - name: Build
+        run: |
+          cd $PYAPP_REPO
+          cargo build --release
+          mv target/release/$SOURCE_FILE ../$TARGET_FILE
+
+      - name: Upload Assets
+        uses: actions/upload-artifact@v4
+        with:
+          name: pdm-${{ github.ref_name }}-${{ env.TARGET_TRIPLE }}
+          path: ${{ env.TARGET_FILE }}
+          if-no-files-found: error
+          retention-days: 15
+
+      - name: Upload to GitHub Release
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          UPLOAD_FILE: pdm-${{ github.ref_name }}-${{ env.TARGET_TRIPLE }}.tar.gz
+        run: |
+          tar -czf $UPLOAD_FILE $TARGET_FILE
+          gh release upload ${{ github.ref_name }} $UPLOAD_FILE --clobber
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4ba7b62..14b9939a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+## Release v2.23.0 (2025-04-01)
+
+### Features & Improvements
+
+- Add `pdm python find` command to search for a python interpreter. ([#3389](https://github.com/pdm-project/pdm/issues/3389))
+- `pdm import` now converts `package-mode` from Poetry's settings table to `distribution`. ([#3427](https://github.com/pdm-project/pdm/issues/3427))
+
+### Bug Fixes
+
+- Excluding non-existing groups for `pdm remove`. ([#3404](https://github.com/pdm-project/pdm/issues/3404))
+- Fix a bug that `pdm add` and `pdm update` remove dependency groups incorrectly. ([#3418](https://github.com/pdm-project/pdm/issues/3418))
+- Fix a bug that using resolution overrides drops extra dependencies. ([#3426](https://github.com/pdm-project/pdm/issues/3426))
+
+
 ## Release v2.22.4 (2025-03-07)
 
 ### Bug Fixes
diff --git a/news/3389.feature.md b/news/3389.feature.md
deleted file mode 100644
index 77b78f5b..00000000
--- a/news/3389.feature.md
+++ /dev/null
@@ -1,1 +0,0 @@
-Add `pdm python find` command to search for a python interpreter.
diff --git a/news/3404.bugfix.md b/news/3404.bugfix.md
deleted file mode 100644
index 4908428d..00000000
--- a/news/3404.bugfix.md
+++ /dev/null
@@ -1,1 +0,0 @@
-Excluding non-existing groups for `pdm remove`.
diff --git a/news/3407.bugfix.md b/news/3407.bugfix.md
new file mode 100644
index 00000000..9ada5b18
--- /dev/null
+++ b/news/3407.bugfix.md
@@ -0,0 +1,1 @@
+Installation error for local plugins specified with file URL without a name.
diff --git a/news/3418.bugfix.md b/news/3418.bugfix.md
deleted file mode 100644
index 4a4da0b7..00000000
--- a/news/3418.bugfix.md
+++ /dev/null
@@ -1,1 +0,0 @@
-Fix a bug that `pdm add` and `pdm update` remove dependency groups incorrectly.
diff --git a/news/3426.bugfix.md b/news/3426.bugfix.md
deleted file mode 100644
index 59788dd2..00000000
--- a/news/3426.bugfix.md
+++ /dev/null
@@ -1,1 +0,0 @@
-Fix a bug that using resolution overrides drops extra dependencies.
diff --git a/news/3427.feature.md b/news/3427.feature.md
deleted file mode 100644
index b989cd56..00000000
--- a/news/3427.feature.md
+++ /dev/null
@@ -1,1 +0,0 @@
-`pdm import` now converts `package-mode` from Poetry's settings table to `distribution`.
diff --git a/news/3429.feature.md b/news/3429.feature.md
new file mode 100644
index 00000000..e674c984
--- /dev/null
+++ b/news/3429.feature.md
@@ -0,0 +1,1 @@
+Use `pyapp` to wrap `pdm` as a Python application that bootstrap itself at runtime.
diff --git a/news/3434.bugfix.md b/news/3434.bugfix.md
new file mode 100644
index 00000000..111c6d2f
--- /dev/null
+++ b/news/3434.bugfix.md
@@ -0,0 +1,1 @@
+Eliminate the warning about inherit_metadata when using uv mode.
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index 958140a1..d7f0949e 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -203,7 +203,7 @@ def resolve_from_lockfile(
             strategies = project.lockfile.strategy.copy()
             if FLAG_INHERIT_METADATA in strategies and groups is not None and not project.config["use_uv"]:
                 return locked_repo.evaluate_candidates(groups)
-            strategies.update((FLAG_STATIC_URLS, FLAG_INHERIT_METADATA))
+            strategies.add(FLAG_STATIC_URLS)
             resolver = project.get_resolver()(
                 environment=project.environment,
                 requirements=reqs,
diff --git a/src/pdm/cli/commands/run.py b/src/pdm/cli/commands/run.py
index 0eb2ad9a..e52b3a70 100644
--- a/src/pdm/cli/commands/run.py
+++ b/src/pdm/cli/commands/run.py
@@ -163,7 +163,9 @@ class TaskRunner:
             return self.project.environment
         tool_config = metadata.pop("tool", {})
         script_project = self.project.core.create_project()
-        script_project.pyproject.set_data({"project": {"name": "temp-project", **metadata}, "tool": tool_config})
+        script_project.pyproject.set_data(
+            {"project": {"name": "temp-project", **metadata, "version": "0.0.0"}, "tool": tool_config}
+        )
         md5_kwargs = {"usedforsecurity": False}
         venv_name = hashlib.md5(os.path.realpath(script_file).encode("utf-8"), **md5_kwargs).hexdigest()
         venv_backend = BACKENDS[script_project.config["venv.backend"]](script_project, None)
diff --git a/src/pdm/models/requirements.py b/src/pdm/models/requirements.py
index e6c625e9..034fc538 100644
--- a/src/pdm/models/requirements.py
+++ b/src/pdm/models/requirements.py
@@ -310,11 +310,6 @@ class FileRequirement(Requirement):
             if not self.url and path.is_absolute():
                 self.url = path.as_uri() + fragments
                 self.path = path
-            # For relative path, we don't resolve URL now, so the path may still contain fragments,
-            # it will be handled in `relocate()` method.
-            result = Setup.from_directory(self.absolute_path)  # type: ignore[arg-type]
-            if result.name:
-                self.name = result.name
         else:
             url = url_without_fragments(self.url)
             relpath = get_relative_path(url)
@@ -326,6 +321,13 @@ class FileRequirement(Requirement):
             else:
                 self.path = Path(relpath)
 
+        if self.path is not None:
+            # For relative path, we don't resolve URL now, so the path may still contain fragments,
+            # it will be handled in `relocate()` method.
+            result = Setup.from_directory(self.absolute_path)  # type: ignore[arg-type]
+            if result.name:
+                self.name = result.name
+
         if self.url:
             self._parse_name_from_url()
 


Test patch:
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 1692c374..5e87ba8d 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -118,3 +118,31 @@ def test_project_plugin_library(pdm, project, core, monkeypatch):
         core.load_plugins()
         result = pdm(["hello", "Frost"], strict=True)
     assert result.stdout.strip() == "Hello, Frost!"
+
+
+@pytest.mark.parametrize(
+    "req_str",
+    [
+        "-e file:///${PROJECT_ROOT}/plugins/test-plugin-pdm",
+        "-e test_plugin_pdm @ file:///${PROJECT_ROOT}/plugins/test-plugin-pdm",
+    ],
+)
+@pytest.mark.usefixtures("local_finder")
+def test_install_local_plugin_without_name(pdm, project, core, req_str):
+    import shutil
+
+    from . import FIXTURES
+
+    test_plugin_path = FIXTURES / "projects" / "test-plugin-pdm"
+    project.root.joinpath("plugins").mkdir(exist_ok=True)
+    shutil.copytree(test_plugin_path, project.root / "plugins" / "test-plugin-pdm", dirs_exist_ok=True)
+
+    project.pyproject.settings["plugins"] = [req_str]
+    project.pyproject.write()
+    with cd(project.root):
+        result = pdm(["install", "--plugins", "-vv"], obj=project, strict=True)
+
+        assert project.root.joinpath(".pdm-plugins").exists()
+        core.load_plugins()
+        result = pdm(["hello", "--name", "Frost"], strict=True)
+    assert result.stdout.strip() == "Hello, Frost"