Repository: pdm-project/pdm (python)

Issue:
pdm lock --update-reuse with URL dependencies generates an invalid lock file
### Describe the bug

In recent versions, updating a dependency with a URL syntax and then using `pdm lock --update-reuse` to update the lock file results in a mismatch. `pdm lock` updates the URL in the lock file, but does not update all other attributes, in particular the version or the file hashes. This leads to a failure in `pdm sync`, because the old hash that is still in the lock file does not match the contents of the updated URL.

This worked in older versions of PDM.

### To reproduce

```shell
$ mkdir proj && cd proj
$ pdm init -n
Creating a pyproject.toml for PDM...
INFO: Using the first matched interpreter.
Virtualenv is created successfully at /home/eltoder/dev/scratch/proj/.venv
Project is initialized successfully
# add any package via URL
$ pdm add "bitarray @ https://files.pythonhosted.org/packages/dc/46/25ebc667907736b2c5c84f4bd8260d9bece8b69719a33db5c3f3dcb281a5/bitarray-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
Adding packages to default dependencies: bitarray @
https://files.pythonhosted.org/packages/dc/46/25ebc667907736b2c5c84f4bd8260d9bece8b69719a33db5c3f3dcb281a5/bitarray-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
  0:00:00 🔒 Lock successful.
Changes are written to pyproject.toml.
Synchronizing working set with resolved packages: 1 to add, 0 to update, 0 to remove

  ✔ Install bitarray 3.0.0 successful

  0:00:00 🎉 All complete! 1/1
# check that this resolved correctly:
$ grep -A 5 bitarray pdm.lock
name = "bitarray"
version = "3.0.0"
url = "https://files.pythonhosted.org/packages/dc/46/25ebc667907736b2c5c84f4bd8260d9bece8b69719a33db5c3f3dcb281a5/bitarray-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
summary = "efficient arrays of booleans -- C extension"
groups = ["default"]
files = [
    {file = "bitarray-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f761184b93092077c7f6b7dad7bd4e671c1620404a76620da7872ceb576a94"},
]
# update package
$ sed -i -e 's|https://files.pythonhosted.org/packages/dc/46/25ebc667907736b2c5c84f4bd8260d9bece8b69719a33db5c3f3dcb281a5/bitarray-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl|https://files.pythonhosted.org/packages/ab/10/67a3bdf78d3d0cf30493aeeeed4346446d304144d1663108f2284fbb8e56/bitarray-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl|' pyproject.toml
$ pdm lock --update-reuse
Changes are written to pdm.lock.
  0:00:00 🔒 Lock successful.
# the URL in the lock file is updated, but version and files are stale
$ grep -A 5 bitarray pdm.lock
name = "bitarray"
version = "3.0.0"
url = "https://files.pythonhosted.org/packages/ab/10/67a3bdf78d3d0cf30493aeeeed4346446d304144d1663108f2284fbb8e56/bitarray-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl"
summary = "efficient arrays of booleans -- C extension"
groups = ["default"]
files = [
    {file = "bitarray-3.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f761184b93092077c7f6b7dad7bd4e671c1620404a76620da7872ceb576a94"},
]
# now sync fails with hash mismatch
$ pdm sync
Synchronizing working set with resolved packages: 0 to add, 1 to update, 0 to remove

  ✖ Update bitarray 3.0.0 -> 3.0.0 failed
  ✖ Update bitarray 3.0.0 -> 3.0.0 failed

ERRORS:
update bitarray failed:
Traceback (most recent call last):
  File "/home/eltoder/.local/share/ext-python/python-3.11.10/lib/python3.11/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File
"/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/pdm/installers/synchronizers.py", line
50, in update_candidate
    self.manager.overwrite(dist, can)
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/pdm/installers/manager.py", line
63, in overwrite
    installed = self.install(candidate)
                ^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/pdm/installers/manager.py", line
33, in install
    prepared.build(),
    ^^^^^^^^^^^^^^^^
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/pdm/models/candidates.py", line
403, in build
    self._obtain(allow_all=False)
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/pdm/models/candidates.py", line
457, in _obtain
    self._unpack(validate_hashes=not allow_all)
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/pdm/models/candidates.py", line
471, in _unpack
    result = finder.download_and_unpack(
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/unearth/finder.py", line 452, in
download_and_unpack
    file = unpack_link(
           ^^^^^^^^^^^^
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/unearth/preparer.py", line 364,
in unpack_link
    validator.validate()
  File "/home/eltoder/.shiv/pdm-2.23.1.shiv_3cd6ed09883d0e61e0d930a9da97034993beb0c7b4569b2fc7cde4fbcf5e7559/site-packages/unearth/preparer.py", line 145,
in validate
    raise HashMismatchError(self.package_link, self.allowed, gots)
unearth.errors.HashMismatchError: Hash mismatch for
https://files.pythonhosted.org/packages/ab/10/67a3bdf78d3d0cf30493aeeeed4346446d304144d1663108f2284fbb8e56/bitarray-3.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
Expected(sha256): d3f761184b93092077c7f6b7dad7bd4e671c1620404a76620da7872ceb576a94
  Actual(sha256): e362fc7a72fd00f641b3d6ed91076174cae36f49183afe8b4b4b77a2b5a116b0
```

### Expected Behavior

`pdm lock` should update both the URL and all other cached attributes.

### Environment Information

```
PDM version:
  2.23.1
Python Interpreter:
  /home/eltoder/dev/scratch/proj/.venv/bin/python (3.11)
Project Root:
  /home/eltoder/dev/scratch/proj
Local Packages:

{
  "implementation_name": "cpython",
  "implementation_version": "3.11.10",
  "os_name": "posix",
  "platform_machine": "x86_64",
  "platform_release": "6.12.8-ts1-amd64",
  "platform_system": "Linux",
  "platform_version": "#1 SMP PREEMPT_DYNAMIC Debian 6.12.8-ts1~debian12 (2025-01-02)",
  "python_full_version": "3.11.10",
  "platform_python_implementation": "CPython",
  "python_version": "3.11",
  "sys_platform": "linux"
}
```

### Verbose Command Output

_No response_

### Additional Context

_No response_

### Are you willing to submit a PR to fix this bug?

- [ ] Yes, I would like to submit a PR.

Pull request description:
fix: pdm lock --update-reuse with URL dependencies generates an invalid lock file
Fixes #3461

Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


Patch:
diff --git a/README.md b/README.md
index f2d1b092..9da9483f 100644
--- a/README.md
+++ b/README.md
@@ -159,10 +159,10 @@ asdf install pdm latest
 
 ## Quickstart
 
-**Initialize a new PDM project**
+**Create a new PDM project**
 
 ```bash
-pdm init
+pdm new my-project
 ```
 
 Answer the questions following the guide, and a PDM project with a `pyproject.toml` file will be ready to use.
diff --git a/README_zh.md b/README_zh.md
index 9ee589c6..69552922 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -152,7 +152,7 @@ asdf install pdm latest
 **初始化一个新的 PDM 项目**
 
 ```bash
-pdm init
+pdm new my_project
 ```
 
 按照指引回答提示的问题，一个 PDM 项目和对应的`pyproject.toml`文件就创建好了。
diff --git a/docs/usage/project.md b/docs/usage/project.md
index 7bae9af0..538f14c4 100644
--- a/docs/usage/project.md
+++ b/docs/usage/project.md
@@ -1,14 +1,22 @@
 # New Project
 
-To start with, create a new project with [`pdm init`](../reference/cli.md#init):
+To start with, create a new project with [`pdm new`](../reference/cli.md#new):
 
 ```bash
-mkdir my-project && cd my-project
-pdm init
+pdm new my-project
 ```
 
 You will need to answer a few questions, to help PDM to create a `pyproject.toml` file for you.
-For more usages of `pdm init`, please read [Create your project from a template](./template.md).
+For more usages of `pdm new`, please read [Create your project from a template](./template.md).
+
+## Create pyproject.toml for an existing project
+
+If you already have a project and want to create a `pyproject.toml` file for it, you can use [`pdm init`](../reference/cli.md#init):
+
+```bash
+cd my-project
+pdm init
+```
 
 ## Choose a Python interpreter
 
diff --git a/docs/usage/template.md b/docs/usage/template.md
index 0cbf8b89..9e51a732 100644
--- a/docs/usage/template.md
+++ b/docs/usage/template.md
@@ -1,17 +1,17 @@
 # Create Project From a Template
 
 Similar to `yarn create` and `npm create`, PDM also supports initializing or creating a project from a template.
-The template is given as a positional argument of `pdm init`, in one of the following forms:
+The template is given as a positional argument of `pdm new`, in one of the following forms:
 
-- `pdm init django` - Initialize the project from the template `https://github.com/pdm-project/template-django`
-- `pdm init https://github.com/frostming/pdm-template-django` - Initialize the project from a Git URL. Both HTTPS and SSH URL are acceptable.
-- `pdm init django@v2` - To check out the specific branch or tag. Full Git URL also supports it.
-- `pdm init /path/to/template` - Initialize the project from a template directory on local filesystem.
-- `pdm init minimal` - Initialize with the builtin "minimal" template, that only generates a `pyproject.toml`.
+- `pdm new django my-project` - Create a new project `my-project` from the template `https://github.com/pdm-project/template-django`
+- `pdm new https://github.com/frostming/pdm-template-django my-project` - Initialize the project from a Git URL. Both HTTPS and SSH URL are acceptable.
+- `pdm new django@v2 my-project` - To check out the specific branch or tag. Full Git URL also supports it.
+- `pdm new /path/to/template my-project` - Initialize the project from a template directory on local filesystem.
+- `pdm new minimal my-project` - Initialize with the builtin "minimal" template, that only generates a `pyproject.toml`.
 
-And `pdm init` will use the default template built in.
+And `pdm new my-project` will use the default template built in and create a project at the given path.
 
-The project will be initialized at the current directory, existing files with the same name will be overwritten. You can also use the `-p <path>` option to create a project at a new path.
+`pdm init` command also supports the same template argument. The project will be initialized at the current directory, existing files with the same name will be overwritten.
 
 ## Contribute a template
 
diff --git a/news/3423.bug.md b/news/3423.bugfix.md
similarity index 100%
rename from news/3423.bug.md
rename to news/3423.bugfix.md
diff --git a/news/3461.bugfix.md b/news/3461.bugfix.md
new file mode 100644
index 00000000..56b91e0a
--- /dev/null
+++ b/news/3461.bugfix.md
@@ -0,0 +1,1 @@
+Fix a bug that URL dependency hashes are not updated if running `pdm lock --update-reuse`.
diff --git a/news/3462.feature.md b/news/3462.feature.md
new file mode 100644
index 00000000..eff271c1
--- /dev/null
+++ b/news/3462.feature.md
@@ -0,0 +1,1 @@
+New command `pdm new` that behaves like `pdm init` but creates a new project.
diff --git a/src/pdm/cli/commands/new.py b/src/pdm/cli/commands/new.py
new file mode 100644
index 00000000..7f049137
--- /dev/null
+++ b/src/pdm/cli/commands/new.py
@@ -0,0 +1,22 @@
+import argparse
+import os
+
+from pdm.cli.commands.base import verbose_option
+from pdm.cli.commands.init import Command as InitCommand
+from pdm.project.core import Project
+
+
+class Command(InitCommand):
+    """Create a new Python project at <project_path>"""
+
+    arguments = (verbose_option,)
+
+    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
+        super().add_arguments(parser)
+        parser.add_argument("project_path", help="The path to create the new project")
+
+    def handle(self, project: Project, options: argparse.Namespace) -> None:
+        new_project = project.core.create_project(
+            options.project_path, global_config=options.config or os.getenv("PDM_CONFIG_FILE")
+        )
+        return super().handle(new_project, options)
diff --git a/src/pdm/resolver/providers.py b/src/pdm/resolver/providers.py
index 850c983e..24a214fe 100644
--- a/src/pdm/resolver/providers.py
+++ b/src/pdm/resolver/providers.py
@@ -370,7 +370,7 @@ class ReusePinProvider(BaseProvider):
         def matches_gen() -> Iterator[Candidate]:
             requested_req = next(filter(lambda r: r.is_named, requirements[identifier]), None)
             for pin in self.iter_reuse_candidates(identifier, requested_req):
-                if identifier not in self.overrides:
+                if identifier not in self.overrides and pin.req.is_named:
                     pin = pin.copy_with(min(requirements[identifier], key=self.requirement_preference))
                 incompat = list(incompatibilities[identifier])
                 pin._preferred = True  # type: ignore[attr-defined]


Test patch:
diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py
index 0f55bf78..4067484b 100644
--- a/tests/cli/test_init.py
+++ b/tests/cli/test_init.py
@@ -5,6 +5,7 @@ import pytest
 
 from pdm.compat import tomllib
 from pdm.models.python import PythonInfo
+from pdm.utils import cd
 
 PYTHON_VERSION = f"{sys.version_info[0]}.{sys.version_info[1]}"
 
@@ -48,6 +49,32 @@ def test_init_command(project_no_init, pdm, mocker):
         assert tomllib.load(fp) == data
 
 
+def test_new_command(project_no_init, pdm, mocker):
+    mocker.patch(
+        "pdm.cli.commands.init.get_user_email_from_git",
+        return_value=("Testing", "me@example.org"),
+    )
+    with cd(project_no_init.root):
+        pdm(["new", "myproject"], input="\ntest-project\n\n\n\n\n\n\n", strict=True)
+    python_version = f"{project_no_init.python.major}.{project_no_init.python.minor}"
+    data = {
+        "project": {
+            "authors": [{"email": "me@example.org", "name": "Testing"}],
+            "dependencies": [],
+            "description": "Default template for PDM package",
+            "license": {"text": "MIT"},
+            "name": "test-project",
+            "requires-python": f"=={python_version}.*",
+            "readme": "README.md",
+            "version": "0.1.0",
+        },
+        "tool": {"pdm": {"distribution": False}},
+    }
+
+    with open(project_no_init.root.joinpath("myproject/pyproject.toml"), "rb") as fp:
+        assert tomllib.load(fp) == data
+
+
 def test_init_command_library(project_no_init, pdm, mocker):
     mocker.patch(
         "pdm.cli.commands.init.get_user_email_from_git",