Skip to content

Commit e910c1d

Browse files
Python: Extend dependency recipes with scope support and package-manager-aware transitive pinning (#6715)
* Add scope support to dependency recipes and introduce ChangeDependency Extend AddDependency, RemoveDependency, and UpgradeDependencyVersion with scope and groupName options to target project.optional-dependencies and dependency-groups in addition to project.dependencies. Generalize PyProjectHelper with isInsideDependencyArray and findDependencyInScope. Rename DependencyInsight scope values to TOML dotted-path convention (e.g. "buildRequires" -> "build-system.requires"). Add ChangeDependency recipe that renames a package across all dependency arrays in pyproject.toml, optionally setting a new version. * Add constraint/override dependencies and UpgradeTransitiveDependencyVersion Add constraintDependencies and overrideDependencies fields to PythonResolutionResult for uv constraint-dependencies and override-dependencies sections. Extract these from pyproject.toml via PythonDependencyParser and link to resolved deps. Extend all dependency recipes (Add, Remove, Upgrade, DependencyInsight) with tool.uv.constraint-dependencies and tool.uv.override-dependencies scope values. Introduce UpgradeTransitiveDependencyVersion recipe that pins a transitive dependency by adding or upgrading a constraint in the [tool.uv].constraint-dependencies array. * Make UpgradeTransitiveDependencyVersion package-manager-aware Add pdmOverrides field to PythonResolutionResult and detect package manager from pyproject.toml table names (tool.uv → Uv, tool.pdm → Pdm). UpgradeTransitiveDependencyVersion now branches by package manager: uv uses constraint-dependencies, PDM uses tool.pdm.overrides, and unknown managers fall back to adding a direct dependency. * Update recipes.csv with new and modified Python dependency recipes
1 parent 4bab9b4 commit e910c1d

18 files changed

Lines changed: 1803 additions & 85 deletions

rewrite-python/src/main/java/org/openrewrite/python/AddDependency.java

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,7 @@
1818
import lombok.EqualsAndHashCode;
1919
import lombok.Value;
2020
import org.jspecify.annotations.Nullable;
21-
import org.openrewrite.ExecutionContext;
22-
import org.openrewrite.Option;
23-
import org.openrewrite.ScanningRecipe;
24-
import org.openrewrite.TreeVisitor;
21+
import org.openrewrite.*;
2522
import org.openrewrite.marker.Markers;
2623
import org.openrewrite.python.internal.PyProjectHelper;
2724
import org.openrewrite.python.marker.PythonResolutionResult;
@@ -55,6 +52,31 @@ public class AddDependency extends ScanningRecipe<AddDependency.Accumulator> {
5552
@Nullable
5653
String version;
5754

55+
@Option(displayName = "Scope",
56+
description = "The dependency scope to add to. Defaults to `project.dependencies`.",
57+
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups",
58+
"tool.uv.constraint-dependencies", "tool.uv.override-dependencies"},
59+
example = "project.dependencies",
60+
required = false)
61+
@Nullable
62+
String scope;
63+
64+
@Option(displayName = "Group name",
65+
description = "The group name, required when scope is `project.optional-dependencies` or `dependency-groups`.",
66+
example = "dev",
67+
required = false)
68+
@Nullable
69+
String groupName;
70+
71+
@Override
72+
public Validated<Object> validate() {
73+
Validated<Object> v = super.validate();
74+
if ("project.optional-dependencies".equals(scope) || "dependency-groups".equals(scope)) {
75+
v = v.and(Validated.required("groupName", groupName));
76+
}
77+
return v;
78+
}
79+
5880
@Override
5981
public String getDisplayName() {
6082
return "Add Python dependency";
@@ -97,8 +119,8 @@ public Toml.Document visitDocument(Toml.Document document, ExecutionContext ctx)
97119

98120
PythonResolutionResult marker = resolution.get();
99121

100-
// Check if the dependency already exists
101-
if (marker.findDependency(packageName) != null) {
122+
// Check if the dependency already exists in the target scope
123+
if (PyProjectHelper.findDependencyInScope(marker, packageName, scope, groupName) != null) {
102124
return document;
103125
}
104126

@@ -140,7 +162,7 @@ private Toml.Document addDependencyToPyproject(Toml.Document document, Execution
140162
public Toml.Array visitArray(Toml.Array array, ExecutionContext ctx) {
141163
Toml.Array a = super.visitArray(array, ctx);
142164

143-
if (!PyProjectHelper.isInsideProjectDependencies(getCursor())) {
165+
if (!PyProjectHelper.isInsideDependencyArray(getCursor(), scope, groupName)) {
144166
return a;
145167
}
146168

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
* Copyright 2026 the original author or authors.
3+
* <p>
4+
* Licensed under the Moderne Source Available License (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
* <p>
8+
* https://docs.moderne.io/licensing/moderne-source-available-license
9+
* <p>
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.openrewrite.python;
17+
18+
import lombok.EqualsAndHashCode;
19+
import lombok.Value;
20+
import org.jspecify.annotations.Nullable;
21+
import org.openrewrite.ExecutionContext;
22+
import org.openrewrite.Option;
23+
import org.openrewrite.ScanningRecipe;
24+
import org.openrewrite.TreeVisitor;
25+
import org.openrewrite.python.internal.PyProjectHelper;
26+
import org.openrewrite.python.marker.PythonResolutionResult;
27+
import org.openrewrite.toml.TomlIsoVisitor;
28+
import org.openrewrite.toml.tree.Toml;
29+
import org.openrewrite.toml.tree.TomlType;
30+
31+
import java.util.*;
32+
33+
/**
34+
* Change a dependency to a different package in pyproject.toml.
35+
* Searches all dependency arrays in the document (no scope restriction).
36+
* When uv is available, the uv.lock file is regenerated to reflect the change.
37+
*/
38+
@EqualsAndHashCode(callSuper = false)
39+
@Value
40+
public class ChangeDependency extends ScanningRecipe<ChangeDependency.Accumulator> {
41+
42+
@Option(displayName = "Old package name",
43+
description = "The current PyPI package name to replace.",
44+
example = "requests")
45+
String oldPackageName;
46+
47+
@Option(displayName = "New package name",
48+
description = "The new PyPI package name.",
49+
example = "httpx")
50+
String newPackageName;
51+
52+
@Option(displayName = "New version",
53+
description = "Optional new PEP 508 version constraint. If not specified, the original version constraint is preserved.",
54+
example = ">=0.24.0",
55+
required = false)
56+
@Nullable
57+
String newVersion;
58+
59+
@Override
60+
public String getDisplayName() {
61+
return "Change Python dependency";
62+
}
63+
64+
@Override
65+
public String getInstanceNameSuffix() {
66+
return String.format("`%s` to `%s`", oldPackageName, newPackageName);
67+
}
68+
69+
@Override
70+
public String getDescription() {
71+
return "Change a dependency to a different package in `pyproject.toml`. " +
72+
"Searches all dependency arrays. When `uv` is available, the `uv.lock` file is regenerated.";
73+
}
74+
75+
static class Accumulator {
76+
final Set<String> projectsToUpdate = new HashSet<>();
77+
final Map<String, String> updatedLockFiles = new HashMap<>();
78+
}
79+
80+
@Override
81+
public Accumulator getInitialValue(ExecutionContext ctx) {
82+
return new Accumulator();
83+
}
84+
85+
@Override
86+
public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
87+
return new TomlIsoVisitor<ExecutionContext>() {
88+
@Override
89+
public Toml.Document visitDocument(Toml.Document document, ExecutionContext ctx) {
90+
if (!document.getSourcePath().toString().endsWith("pyproject.toml")) {
91+
return document;
92+
}
93+
Optional<PythonResolutionResult> resolution = document.getMarkers()
94+
.findFirst(PythonResolutionResult.class);
95+
if (!resolution.isPresent()) {
96+
return document;
97+
}
98+
99+
PythonResolutionResult marker = resolution.get();
100+
if (marker.findDependencyInAnyScope(oldPackageName) != null) {
101+
acc.projectsToUpdate.add(document.getSourcePath().toString());
102+
}
103+
return document;
104+
}
105+
};
106+
}
107+
108+
@Override
109+
public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
110+
return new TomlIsoVisitor<ExecutionContext>() {
111+
@Override
112+
public Toml.Document visitDocument(Toml.Document document, ExecutionContext ctx) {
113+
String sourcePath = document.getSourcePath().toString();
114+
115+
if (sourcePath.endsWith("pyproject.toml") && acc.projectsToUpdate.contains(sourcePath)) {
116+
return changeDependencyInPyproject(document, ctx, acc);
117+
}
118+
119+
if (sourcePath.endsWith("uv.lock")) {
120+
String pyprojectPath = PyProjectHelper.correspondingPyprojectPath(sourcePath);
121+
String newContent = acc.updatedLockFiles.get(pyprojectPath);
122+
if (newContent != null) {
123+
return PyProjectHelper.reparseToml(document, newContent);
124+
}
125+
}
126+
127+
return document;
128+
}
129+
};
130+
}
131+
132+
private Toml.Document changeDependencyInPyproject(Toml.Document document, ExecutionContext ctx, Accumulator acc) {
133+
String normalizedOld = PythonResolutionResult.normalizeName(oldPackageName);
134+
135+
Toml.Document updated = (Toml.Document) new TomlIsoVisitor<ExecutionContext>() {
136+
@Override
137+
public Toml.Literal visitLiteral(Toml.Literal literal, ExecutionContext ctx) {
138+
Toml.Literal l = super.visitLiteral(literal, ctx);
139+
if (l.getType() != TomlType.Primitive.String) {
140+
return l;
141+
}
142+
143+
Object val = l.getValue();
144+
if (!(val instanceof String)) {
145+
return l;
146+
}
147+
148+
String spec = (String) val;
149+
String depName = PyProjectHelper.extractPackageName(spec);
150+
if (depName == null || !PythonResolutionResult.normalizeName(depName).equals(normalizedOld)) {
151+
return l;
152+
}
153+
154+
// Build new PEP 508 string
155+
String extras = UpgradeDependencyVersion.extractExtras(spec);
156+
String marker = UpgradeDependencyVersion.extractMarker(spec);
157+
158+
StringBuilder sb = new StringBuilder(newPackageName);
159+
if (extras != null) {
160+
sb.append('[').append(extras).append(']');
161+
}
162+
if (newVersion != null) {
163+
sb.append(newVersion);
164+
} else {
165+
// Preserve the original version constraint
166+
String originalVersion = extractVersionConstraint(spec, depName);
167+
if (originalVersion != null) {
168+
sb.append(originalVersion);
169+
}
170+
}
171+
if (marker != null) {
172+
sb.append("; ").append(marker);
173+
}
174+
175+
String newSpec = sb.toString();
176+
return l.withSource("\"" + newSpec + "\"").withValue(newSpec);
177+
}
178+
}.visitNonNull(document, ctx);
179+
180+
if (updated != document) {
181+
updated = PyProjectHelper.regenerateLockAndRefreshMarker(updated, acc.updatedLockFiles);
182+
}
183+
184+
return updated;
185+
}
186+
187+
/**
188+
* Extract the version constraint portion from a PEP 508 spec.
189+
* Returns the version constraint (e.g. ">=2.28.0") or null if there is none.
190+
*/
191+
private static @Nullable String extractVersionConstraint(String spec, String name) {
192+
String remaining = spec.substring(name.length()).trim();
193+
// Skip extras [...]
194+
if (remaining.startsWith("[")) {
195+
int end = remaining.indexOf(']');
196+
if (end >= 0) {
197+
remaining = remaining.substring(end + 1).trim();
198+
}
199+
}
200+
// Extract version constraint up to marker
201+
int markerIdx = remaining.indexOf(';');
202+
String versionPart = markerIdx >= 0 ? remaining.substring(0, markerIdx).trim() : remaining.trim();
203+
return versionPart.isEmpty() ? null : versionPart;
204+
}
205+
}

rewrite-python/src/main/java/org/openrewrite/python/PyProjectTomlParser.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ private PythonResolutionResult resolveFromLockFile(PythonResolutionResult marker
9090
marker = marker.withBuildRequires(linkResolved(marker.getBuildRequires(), resolvedDeps));
9191
marker = marker.withOptionalDependencies(linkResolvedMap(marker.getOptionalDependencies(), resolvedDeps));
9292
marker = marker.withDependencyGroups(linkResolvedMap(marker.getDependencyGroups(), resolvedDeps));
93+
marker = marker.withConstraintDependencies(linkResolved(marker.getConstraintDependencies(), resolvedDeps));
94+
marker = marker.withOverrideDependencies(linkResolved(marker.getOverrideDependencies(), resolvedDeps));
9395

9496
return marker;
9597
}

rewrite-python/src/main/java/org/openrewrite/python/RemoveDependency.java

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,8 @@
1717

1818
import lombok.EqualsAndHashCode;
1919
import lombok.Value;
20-
import org.openrewrite.ExecutionContext;
21-
import org.openrewrite.Option;
22-
import org.openrewrite.ScanningRecipe;
23-
import org.openrewrite.TreeVisitor;
20+
import org.jspecify.annotations.Nullable;
21+
import org.openrewrite.*;
2422
import org.openrewrite.python.internal.PyProjectHelper;
2523
import org.openrewrite.python.marker.PythonResolutionResult;
2624
import org.openrewrite.toml.TomlIsoVisitor;
@@ -43,6 +41,31 @@ public class RemoveDependency extends ScanningRecipe<RemoveDependency.Accumulato
4341
example = "requests")
4442
String packageName;
4543

44+
@Option(displayName = "Scope",
45+
description = "The dependency scope to remove from. Defaults to `project.dependencies`.",
46+
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups",
47+
"tool.uv.constraint-dependencies", "tool.uv.override-dependencies"},
48+
example = "project.dependencies",
49+
required = false)
50+
@Nullable
51+
String scope;
52+
53+
@Option(displayName = "Group name",
54+
description = "The group name, required when scope is `project.optional-dependencies` or `dependency-groups`.",
55+
example = "dev",
56+
required = false)
57+
@Nullable
58+
String groupName;
59+
60+
@Override
61+
public Validated<Object> validate() {
62+
Validated<Object> v = super.validate();
63+
if ("project.optional-dependencies".equals(scope) || "dependency-groups".equals(scope)) {
64+
v = v.and(Validated.required("groupName", groupName));
65+
}
66+
return v;
67+
}
68+
4669
@Override
4770
public String getDisplayName() {
4871
return "Remove Python dependency";
@@ -85,8 +108,8 @@ public Toml.Document visitDocument(Toml.Document document, ExecutionContext ctx)
85108

86109
PythonResolutionResult marker = resolution.get();
87110

88-
// Check if the dependency exists
89-
if (marker.findDependency(packageName) == null) {
111+
// Check if the dependency exists in the target scope
112+
if (PyProjectHelper.findDependencyInScope(marker, packageName, scope, groupName) == null) {
90113
return document;
91114
}
92115

@@ -128,7 +151,7 @@ private Toml.Document removeDependencyFromPyproject(Toml.Document document, Exec
128151
public Toml.Array visitArray(Toml.Array array, ExecutionContext ctx) {
129152
Toml.Array a = super.visitArray(array, ctx);
130153

131-
if (!PyProjectHelper.isInsideProjectDependencies(getCursor())) {
154+
if (!PyProjectHelper.isInsideDependencyArray(getCursor(), scope, groupName)) {
132155
return a;
133156
}
134157

0 commit comments

Comments
 (0)