Repository: pdm-project/pdm

Issue:
Inconsistent behaviour of `pdm add` in mode direct_minimal_versions
- [ x] I have searched the issue tracker and believe that this is not a duplicate.

## Steps to reproduce

1. Create an empty project, with no dependencies
2. run `pdm lock --strategy direct_minimal_versions` to activate the locking strategy
3. run `pdm add django`. This will modify the pyproject file to install the latest compatible django package (-> `django>=4.2.11` for python 3.9)
4. run again `pdm add django`. The pyproject file will now be modified to install the earliest compatible django package (-> `django>=1.1.3`)


## Expected behavior

* Running `pdm add` a second time should have no effect

## Environment Information

```bash
# Paste the output of `pdm info && pdm info --env` below:
PDM version:
  2.15.1
Python Interpreter:
  D:\tmp\test_pdm\.venv\Scripts\python.exe (3.9)
Project Root:
  D:/tmp/test_pdm
Local Packages:

{
  "implementation_name": "cpython",
  "implementation_version": "3.9.13",
  "os_name": "nt",
  "platform_machine": "AMD64",
  "platform_release": "10",
  "platform_system": "Windows",
  "platform_version": "10.0.19045",
  "python_full_version": "3.9.13",
  "platform_python_implementation": "CPython",
  "python_version": "3.9",
  "sys_platform": "win32"
}
```

## Discussion

* One could wonder whether `pdm add` should look for the latest or earliest compatible version on its first call. IMHO, the latest version should be sought, even in direct_minimal_versions strategy, since that strategy specifies how the lock file consistency with the pyproject file is enforced, not how the pyproject file is specified
* Another issue is what `pdm add` should do with respect to what is already specified in the pyproject file. The documentation doesn't say much about the policies followed by PDM if `pdm add` is called for an already-specified dependency
* When reading the documentation, it is not obvious how to reliably increase the lower bounds of the dependencies specified in the pyproject file with PDM. Should we always first edit the pyproject file and then run `pdm install`?

Thanks for the awesome work done on PDM!


Discussion:
> IMHO, the latest version should be sought, even in direct_minimal_versions strategy, since that strategy specifies how the lock file consistency with the pyproject file is enforced, not how the pyproject file is specified

I think, on the contrary, the lowest version should be locked.



> * Another issue is what `pdm add` should do with respect to what is already specified in the pyproject file.

It means to "overwrite" what is stored in the `pyproject.toml`



> * When reading the documentation, it is not obvious how to reliably increase the lower bounds of the dependencies specified in the pyproject file with PDM.

`pdm update --unconstrained` for this. It is to "ignore" the version ranges in the `pyproject.toml` temporarily and update the pinned version, then recalculate the version ranges based on the [save strategy](https://pdm-project.org/en/latest/usage/dependency/#save-version-specifiers) and write it back to `pyproject.toml`
Hi @frostming ,

Thanks for having speedily fixed this and taken the time to answer my comments!

Regarding your "lowest version" policy for `pdm add <new>` under the direct_minimal_versions strategy, I might be using PDM wrong but it looks like it will make it harder to use. We use direct_minimal_versions to ensure that the lower version bounds in `pyproject.toml` are tested, and therefore meaningful. When adding a new dependency in development, we do want to take the last stable version, since we will use the last features and documentation for new developments with that dependency. With this "lowest version" policy, I guess we will have to set the version we want explicitly with `pdm add new_dep>=last_ver`. Am I correct?
You should probably use two lock files, one with latest versions (the default one), one with lowest versions (only used in CI, or maybe also when running tests locally).
Yes, basically you add dependencies to the default lock file which prefers latest and run `pdm lock -L pdm.min.lock -S direct_minimal_versions`(The `-S ...` can be omitted for subsequent runs) to produce another lock with minimal versions.
Hi @pawamoy @frostming , thank you both for your feedback. I didn't think of using two lock files. I have to give it some thoughts.

Have a nice day!



Patch:
diff --git a/news/2853.bugfix.md b/news/2853.bugfix.md
new file mode 100644
index 0000000000..c354179093
--- /dev/null
+++ b/news/2853.bugfix.md
@@ -0,0 +1,1 @@
+Make `direct_minimal_versions` work on newly added dependencies.
diff --git a/src/pdm/cli/commands/add.py b/src/pdm/cli/commands/add.py
index 5f56bc52bb..df4d2ce1e2 100644
--- a/src/pdm/cli/commands/add.py
+++ b/src/pdm/cli/commands/add.py
@@ -155,6 +155,8 @@ def do_add(
         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)
         with hooks.skipping("post_lock"):
             resolved = do_lock(
                 project,
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index fa3a3ea1ba..89014ebb1e 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -570,6 +570,7 @@ def add_dependencies(
         to_group: str = "default",
         dev: bool = False,
         show_message: bool = True,
+        write: bool = True,
     ) -> None:
         deps, setter = self.use_pyproject_dependencies(to_group, dev)
         for _, dep in requirements.items():
@@ -583,7 +584,8 @@ def add_dependencies(
             else:
                 deps[matched_index] = req
         setter(cast(Array, deps).multiline(True))
-        self.pyproject.write(show_message)
+        if write:
+            self.pyproject.write(show_message)
 
     def init_global_project(self) -> None:
         if not self.is_global or not self.pyproject.empty():


Test patch:
diff --git a/tests/cli/test_add.py b/tests/cli/test_add.py
index 564938e7a9..22a5270df3 100644
--- a/tests/cli/test_add.py
+++ b/tests/cli/test_add.py
@@ -256,6 +256,7 @@ def test_add_cached_vcs_requirement(project, mocker, pdm):
 @pytest.mark.usefixtures("repository")
 def test_add_with_dry_run(project, pdm):
     result = pdm(["add", "--dry-run", "requests"], obj=project, strict=True)
+    project.pyproject.reload()
     assert not project.get_dependencies()
     assert "requests 2.19.1" in result.stdout
     assert "urllib3 1.22" in result.stdout
@@ -330,3 +331,14 @@ def test_add_disable_cache(project, pdm, working_set):
 
     files = [file for file in cache_dir.rglob("*") if file.is_file()]
     assert not files
+
+
+@pytest.mark.usefixtures("working_set")
+def test_add_dependency_with_direct_minimal_versions(project, pdm, repository):
+    pdm(["lock", "-S", "direct_minimal_versions"], obj=project, strict=True)
+    repository.add_candidate("pytz", "2019.6")
+    pdm(["add", "django"], obj=project, strict=True)
+    all_candidates = project.locked_repository.all_candidates
+    assert "django>=1.11.8" in project.pyproject.metadata["dependencies"]
+    assert all_candidates["django"].version == "1.11.8"
+    assert all_candidates["pytz"].version == "2019.6"