Repository: pdm-project/pdm
Pull request #2714 (2024-03-22 07:38:42)

feat: allow updating specific sub-deps with `--allow-transitive` flag (#2689)

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.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/2714?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 84.87%. Comparing base [(`93cd081`)](https://app.codecov.io/gh/pdm-project/pdm/commit/93cd08104a8c3b2bafff039f2824ebb7691f192f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`1a5db59`)](https://app.codecov.io/gh/pdm-project/pdm/pull/2714?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2714      +/-   ##
==========================================
+ Coverage   84.83%   84.87%   +0.03%     
==========================================
  Files         104      104              
  Lines       10325    10332       +7     
  Branches     2243     2244       +1     
==========================================
+ Hits         8759     8769      +10     
+ Misses       1094     1093       -1     
+ Partials      472      470       -2     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/2714/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/2714/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `84.65% <100.00%> (+0.03%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/2714?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/2628.feature.md b/news/2628.feature.md
new file mode 100644
index 00000000..1bbce3a2
--- /dev/null
+++ b/news/2628.feature.md
@@ -0,0 +1,1 @@
+Allow updating specific sub-dependencies (i.e., transitive dependencies) in the lock file.
diff --git a/src/pdm/cli/commands/update.py b/src/pdm/cli/commands/update.py
index 39561b6f..1ccaeb05 100644
--- a/src/pdm/cli/commands/update.py
+++ b/src/pdm/cli/commands/update.py
@@ -123,34 +123,38 @@ class Command(BaseCommand):
         all_dependencies = project.all_dependencies
         updated_deps: dict[str, dict[str, Requirement]] = defaultdict(dict)
         locked_groups = project.lockfile.groups
+        tracked_names: set[str] = set()
         if not packages:
             if prerelease is not None:
                 raise PdmUsageError("--prerelease/--stable must be used with packages given")
             selection.validate()
             for group in selection:
                 updated_deps[group] = all_dependencies[group]
+            tracked_names.update(chain.from_iterable(updated_deps.values()))
         else:
             group = selection.one()
             if locked_groups and group not in locked_groups:
                 raise ProjectError(f"Requested group not in lockfile: {group}")
             dependencies = all_dependencies[group]
             for name in packages:
-                matched_name = next(
-                    (k for k in dependencies if normalize_name(strip_extras(k)[0]) == normalize_name(name)),
+                normalized_name = normalize_name(name)
+                matched_req = next(
+                    (v for k, v in dependencies.items() if normalize_name(strip_extras(k)[0]) == normalized_name),
                     None,
                 )
-                if not matched_name:
-                    raise ProjectError(
-                        f"[req]{name}[/] does not exist in [primary]{group}[/] "
-                        f"{'dev-' if selection.dev else ''}dependencies."
-                    )
-                dependencies[matched_name].prerelease = prerelease
-                updated_deps[group][matched_name] = dependencies[matched_name]
-            project.core.ui.echo(
-                "Updating packages: {}.".format(
-                    ", ".join(f"[req]{v}[/]" for v in chain.from_iterable(updated_deps.values()))
-                )
-            )
+                if not matched_req:
+                    candidates = project.locked_repository.all_candidates
+                    if normalized_name not in candidates:
+                        raise ProjectError(
+                            f"[req]{name}[/] does not exist in [primary]{group}[/] "
+                            f"{'dev-' if selection.dev else ''}dependencies nor is a transitive dependency."
+                        )
+                    tracked_names.add(normalized_name)
+                else:
+                    matched_req.prerelease = prerelease
+                    updated_deps[group][normalized_name] = matched_req
+            tracked_names.update(chain.from_iterable(updated_deps.values()))
+            project.core.ui.echo("Updating packages: {}.".format(", ".join(f"[req]{v}[/]" for v in tracked_names)))
         if unconstrained:
             for deps in updated_deps.values():
                 for dep in deps.values():
@@ -167,9 +171,9 @@ class Command(BaseCommand):
         with hooks.skipping("pre_lock", "post_lock"):
             resolved = do_lock(
                 project,
-                strategy,
-                chain.from_iterable(updated_deps.values()),
-                reqs,
+                strategy=strategy,
+                tracked_names=tracked_names,
+                requirements=reqs,
                 dry_run=True,
                 hooks=hooks,
                 groups=locked_groups,


Test patch:
diff --git a/tests/cli/test_update.py b/tests/cli/test_update.py
index e59a4c46..1b111aa9 100644
--- a/tests/cli/test_update.py
+++ b/tests/cli/test_update.py
@@ -161,6 +161,60 @@ def test_update_specified_packages_eager_mode(project, repository, pdm):
     assert locked_candidates["pytz"].version == "2019.3"
 
 
+@pytest.mark.usefixtures("working_set")
+def test_update_transitive(project, repository, pdm):
+    pdm(["add", "requests", "--no-sync"], obj=project, strict=True)
+    repository.add_candidate("chardet", "3.0.5")
+    repository.add_candidate("requests", "2.20.0")
+    repository.add_dependencies(
+        "requests",
+        "2.20.0",
+        [
+            "certifi>=2017.4.17",
+            "chardet<3.1.0,>=3.0.2",
+            "idna<2.8,>=2.5",
+            "urllib3<1.24,>=1.21.1",
+        ],
+    )
+    pdm(["update", "chardet"], obj=project, strict=True)
+    locked_candidates = project.locked_repository.all_candidates
+    assert not any("chardet" in dependency for dependency in project.pyproject.metadata["dependencies"])
+    assert locked_candidates["chardet"].version == "3.0.5"
+    assert locked_candidates["requests"].version == "2.19.1"
+
+
+@pytest.mark.usefixtures("working_set")
+def test_update_transitive_nonexistant_dependencies(project, repository, pdm):
+    pdm(["add", "requests", "--no-sync"], obj=project, strict=True)
+    result = pdm(["update", "numpy"])
+    assert "ProjectError" in result.stderr
+    assert "numpy does not exist in" in result.stderr
+
+
+@pytest.mark.usefixtures("working_set")
+def test_update_transitive_non_transitive_dependencies(project, repository, pdm):
+    pdm(["add", "requests", "pytz", "--no-sync"], obj=project, strict=True)
+    repository.add_candidate("pytz", "2019.6")
+    repository.add_candidate("chardet", "3.0.5")
+    repository.add_candidate("requests", "2.20.0")
+    repository.add_dependencies(
+        "requests",
+        "2.20.0",
+        [
+            "certifi>=2017.4.17",
+            "chardet<3.1.0,>=3.0.2",
+            "idna<2.8,>=2.5",
+            "urllib3<1.24,>=1.21.1",
+        ],
+    )
+    pdm(["update", "requests", "chardet", "pytz"], obj=project, strict=True)
+    locked_candidates = project.locked_repository.all_candidates
+    assert not any("chardet" in dependency for dependency in project.pyproject.metadata["dependencies"])
+    assert locked_candidates["requests"].version == "2.20.0"
+    assert locked_candidates["chardet"].version == "3.0.5"
+    assert locked_candidates["pytz"].version == "2019.6"
+
+
 @pytest.mark.usefixtures("working_set")
 def test_update_specified_packages_eager_mode_config(project, repository, pdm):
     pdm(["add", "requests", "pytz", "--no-sync"], obj=project, strict=True)