Skip to content

Commit ed00208

Browse files
committed
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.
1 parent 12f516b commit ed00208

11 files changed

Lines changed: 627 additions & 5 deletions

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ public class AddDependency extends ScanningRecipe<AddDependency.Accumulator> {
5454

5555
@Option(displayName = "Scope",
5656
description = "The dependency scope to add to. Defaults to `project.dependencies`.",
57-
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups"},
57+
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups",
58+
"tool.uv.constraint-dependencies", "tool.uv.override-dependencies"},
5859
example = "project.dependencies",
5960
required = false)
6061
@Nullable

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ public class RemoveDependency extends ScanningRecipe<RemoveDependency.Accumulato
4343

4444
@Option(displayName = "Scope",
4545
description = "The dependency scope to remove from. Defaults to `project.dependencies`.",
46-
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups"},
46+
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups",
47+
"tool.uv.constraint-dependencies", "tool.uv.override-dependencies"},
4748
example = "project.dependencies",
4849
required = false)
4950
@Nullable

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ public class UpgradeDependencyVersion extends ScanningRecipe<UpgradeDependencyVe
4747

4848
@Option(displayName = "Scope",
4949
description = "The dependency scope to update in. Defaults to `project.dependencies`.",
50-
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups"},
50+
valid = {"project.dependencies", "project.optional-dependencies", "dependency-groups",
51+
"tool.uv.constraint-dependencies", "tool.uv.override-dependencies"},
5152
example = "project.dependencies",
5253
required = false)
5354
@Nullable
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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.*;
22+
import org.openrewrite.marker.Markers;
23+
import org.openrewrite.python.internal.PyProjectHelper;
24+
import org.openrewrite.python.marker.PythonResolutionResult;
25+
import org.openrewrite.python.marker.PythonResolutionResult.Dependency;
26+
import org.openrewrite.toml.TomlIsoVisitor;
27+
import org.openrewrite.toml.tree.Space;
28+
import org.openrewrite.toml.tree.Toml;
29+
import org.openrewrite.toml.tree.TomlRightPadded;
30+
import org.openrewrite.toml.tree.TomlType;
31+
32+
import java.util.*;
33+
34+
import static org.openrewrite.Tree.randomId;
35+
36+
/**
37+
* Pin a transitive dependency version by adding or upgrading a constraint in the
38+
* appropriate tool-specific section. For uv projects, this uses
39+
* {@code [tool.uv].constraint-dependencies}.
40+
*/
41+
@EqualsAndHashCode(callSuper = false)
42+
@Value
43+
public class UpgradeTransitiveDependencyVersion extends ScanningRecipe<UpgradeTransitiveDependencyVersion.Accumulator> {
44+
45+
@Option(displayName = "Package name",
46+
description = "The PyPI package name of the transitive dependency to pin.",
47+
example = "certifi")
48+
String packageName;
49+
50+
@Option(displayName = "Version",
51+
description = "The PEP 508 version constraint (e.g., `>=2023.7.22`).",
52+
example = ">=2023.7.22")
53+
String version;
54+
55+
@Override
56+
public String getDisplayName() {
57+
return "Upgrade transitive Python dependency version";
58+
}
59+
60+
@Override
61+
public String getInstanceNameSuffix() {
62+
return String.format("`%s` to `%s`", packageName, version);
63+
}
64+
65+
@Override
66+
public String getDescription() {
67+
return "Pin a transitive dependency version by adding or upgrading a constraint. " +
68+
"For uv projects, this uses `[tool.uv].constraint-dependencies`. " +
69+
"When `uv` is available, the `uv.lock` file is regenerated.";
70+
}
71+
72+
enum Action {
73+
NONE,
74+
ADD_CONSTRAINT,
75+
UPGRADE_CONSTRAINT
76+
}
77+
78+
static class Accumulator {
79+
final Set<String> projectsToUpdate = new HashSet<>();
80+
final Map<String, String> updatedLockFiles = new HashMap<>();
81+
final Map<String, Action> actions = new HashMap<>();
82+
}
83+
84+
@Override
85+
public Accumulator getInitialValue(ExecutionContext ctx) {
86+
return new Accumulator();
87+
}
88+
89+
@Override
90+
public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
91+
return new TomlIsoVisitor<ExecutionContext>() {
92+
@Override
93+
public Toml.Document visitDocument(Toml.Document document, ExecutionContext ctx) {
94+
if (!document.getSourcePath().toString().endsWith("pyproject.toml")) {
95+
return document;
96+
}
97+
Optional<PythonResolutionResult> resolution = document.getMarkers()
98+
.findFirst(PythonResolutionResult.class);
99+
if (!resolution.isPresent()) {
100+
return document;
101+
}
102+
103+
PythonResolutionResult marker = resolution.get();
104+
String sourcePath = document.getSourcePath().toString();
105+
106+
// Skip if this is a direct dependency
107+
if (marker.findDependency(packageName) != null) {
108+
return document;
109+
}
110+
111+
// For uv projects, require resolved dependencies to verify it's transitive
112+
if (marker.getResolvedDependency(packageName) == null) {
113+
return document;
114+
}
115+
116+
// Check if a constraint already exists
117+
Dependency existingConstraint = PyProjectHelper.findDependencyInScope(
118+
marker, packageName, "tool.uv.constraint-dependencies", null);
119+
120+
if (existingConstraint == null) {
121+
acc.actions.put(sourcePath, Action.ADD_CONSTRAINT);
122+
} else if (!version.equals(existingConstraint.getVersionConstraint())) {
123+
acc.actions.put(sourcePath, Action.UPGRADE_CONSTRAINT);
124+
} else {
125+
return document;
126+
}
127+
128+
acc.projectsToUpdate.add(sourcePath);
129+
return document;
130+
}
131+
};
132+
}
133+
134+
@Override
135+
public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
136+
return new TomlIsoVisitor<ExecutionContext>() {
137+
@Override
138+
public Toml.Document visitDocument(Toml.Document document, ExecutionContext ctx) {
139+
String sourcePath = document.getSourcePath().toString();
140+
141+
if (sourcePath.endsWith("pyproject.toml") && acc.projectsToUpdate.contains(sourcePath)) {
142+
Action action = acc.actions.get(sourcePath);
143+
if (action == Action.ADD_CONSTRAINT) {
144+
return addConstraint(document, ctx, acc);
145+
} else if (action == Action.UPGRADE_CONSTRAINT) {
146+
return upgradeConstraint(document, ctx, acc);
147+
}
148+
}
149+
150+
if (sourcePath.endsWith("uv.lock")) {
151+
String pyprojectPath = PyProjectHelper.correspondingPyprojectPath(sourcePath);
152+
String newContent = acc.updatedLockFiles.get(pyprojectPath);
153+
if (newContent != null) {
154+
return PyProjectHelper.reparseToml(document, newContent);
155+
}
156+
}
157+
158+
return document;
159+
}
160+
};
161+
}
162+
163+
private Toml.Document addConstraint(Toml.Document document, ExecutionContext ctx, Accumulator acc) {
164+
String pep508 = packageName + version;
165+
166+
Toml.Document updated = (Toml.Document) new TomlIsoVisitor<ExecutionContext>() {
167+
@Override
168+
public Toml.Array visitArray(Toml.Array array, ExecutionContext ctx) {
169+
Toml.Array a = super.visitArray(array, ctx);
170+
171+
if (!PyProjectHelper.isInsideDependencyArray(getCursor(), "tool.uv.constraint-dependencies", null)) {
172+
return a;
173+
}
174+
175+
Toml.Literal newLiteral = new Toml.Literal(
176+
randomId(),
177+
Space.EMPTY,
178+
Markers.EMPTY,
179+
TomlType.Primitive.String,
180+
"\"" + pep508 + "\"",
181+
pep508
182+
);
183+
184+
List<TomlRightPadded<Toml>> existingPadded = a.getPadding().getValues();
185+
List<TomlRightPadded<Toml>> newPadded = new ArrayList<>();
186+
187+
boolean isEmpty = existingPadded.size() == 1 &&
188+
existingPadded.get(0).getElement() instanceof Toml.Empty;
189+
if (existingPadded.isEmpty() || isEmpty) {
190+
newPadded.add(new TomlRightPadded<>(newLiteral, Space.EMPTY, Markers.EMPTY));
191+
} else {
192+
TomlRightPadded<Toml> lastPadded = existingPadded.get(existingPadded.size() - 1);
193+
boolean hasTrailingComma = lastPadded.getElement() instanceof Toml.Empty;
194+
195+
if (hasTrailingComma) {
196+
int lastRealIdx = existingPadded.size() - 2;
197+
Toml lastRealElement = existingPadded.get(lastRealIdx).getElement();
198+
Toml.Literal formattedLiteral = newLiteral.withPrefix(lastRealElement.getPrefix());
199+
200+
for (int i = 0; i <= lastRealIdx; i++) {
201+
newPadded.add(existingPadded.get(i));
202+
}
203+
newPadded.add(new TomlRightPadded<>(formattedLiteral, Space.EMPTY, Markers.EMPTY));
204+
newPadded.add(lastPadded);
205+
} else {
206+
Toml lastElement = lastPadded.getElement();
207+
Space newPrefix = lastElement.getPrefix().getWhitespace().contains("\n")
208+
? lastElement.getPrefix()
209+
: Space.SINGLE_SPACE;
210+
Toml.Literal formattedLiteral = newLiteral.withPrefix(newPrefix);
211+
212+
for (int i = 0; i < existingPadded.size() - 1; i++) {
213+
newPadded.add(existingPadded.get(i));
214+
}
215+
newPadded.add(lastPadded.withAfter(Space.EMPTY));
216+
newPadded.add(new TomlRightPadded<>(formattedLiteral, lastPadded.getAfter(), Markers.EMPTY));
217+
}
218+
}
219+
220+
return a.getPadding().withValues(newPadded);
221+
}
222+
}.visitNonNull(document, ctx);
223+
224+
if (updated != document) {
225+
updated = PyProjectHelper.regenerateLockAndRefreshMarker(updated, acc.updatedLockFiles);
226+
}
227+
228+
return updated;
229+
}
230+
231+
private Toml.Document upgradeConstraint(Toml.Document document, ExecutionContext ctx, Accumulator acc) {
232+
String normalizedName = PythonResolutionResult.normalizeName(packageName);
233+
234+
Toml.Document updated = (Toml.Document) new TomlIsoVisitor<ExecutionContext>() {
235+
@Override
236+
public Toml.Literal visitLiteral(Toml.Literal literal, ExecutionContext ctx) {
237+
Toml.Literal l = super.visitLiteral(literal, ctx);
238+
if (l.getType() != TomlType.Primitive.String) {
239+
return l;
240+
}
241+
242+
Object val = l.getValue();
243+
if (!(val instanceof String)) {
244+
return l;
245+
}
246+
247+
// Check if we're inside [tool.uv].constraint-dependencies
248+
if (!isInsideConstraintDependencies()) {
249+
return l;
250+
}
251+
252+
String spec = (String) val;
253+
String depName = PyProjectHelper.extractPackageName(spec);
254+
if (depName == null || !PythonResolutionResult.normalizeName(depName).equals(normalizedName)) {
255+
return l;
256+
}
257+
258+
String extras = UpgradeDependencyVersion.extractExtras(spec);
259+
String marker = UpgradeDependencyVersion.extractMarker(spec);
260+
261+
StringBuilder sb = new StringBuilder(depName);
262+
if (extras != null) {
263+
sb.append('[').append(extras).append(']');
264+
}
265+
sb.append(version);
266+
if (marker != null) {
267+
sb.append("; ").append(marker);
268+
}
269+
270+
String newSpec = sb.toString();
271+
return l.withSource("\"" + newSpec + "\"").withValue(newSpec);
272+
}
273+
274+
private boolean isInsideConstraintDependencies() {
275+
Cursor c = getCursor();
276+
while (c != null) {
277+
if (c.getValue() instanceof Toml.Array) {
278+
return PyProjectHelper.isInsideDependencyArray(c, "tool.uv.constraint-dependencies", null);
279+
}
280+
c = c.getParent();
281+
}
282+
return false;
283+
}
284+
}.visitNonNull(document, ctx);
285+
286+
if (updated != document) {
287+
updated = PyProjectHelper.regenerateLockAndRefreshMarker(updated, acc.updatedLockFiles);
288+
}
289+
290+
return updated;
291+
}
292+
}

rewrite-python/src/main/java/org/openrewrite/python/internal/PyProjectHelper.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ public static boolean isInsideProjectDependencies(Cursor cursor) {
142142
* <li>{@code "build-system.requires"} → {@code [build-system].requires}</li>
143143
* <li>{@code "project.optional-dependencies"} → {@code [project.optional-dependencies].<groupName>}</li>
144144
* <li>{@code "dependency-groups"} → {@code [dependency-groups].<groupName>}</li>
145+
* <li>{@code "tool.uv.constraint-dependencies"} → {@code [tool.uv].constraint-dependencies}</li>
146+
* <li>{@code "tool.uv.override-dependencies"} → {@code [tool.uv].override-dependencies}</li>
145147
* </ul>
146148
*/
147149
public static boolean isInsideDependencyArray(Cursor cursor, @Nullable String scope, @Nullable String groupName) {
@@ -159,6 +161,12 @@ public static boolean isInsideDependencyArray(Cursor cursor, @Nullable String sc
159161
} else if ("dependency-groups".equals(scope)) {
160162
tableName = "dependency-groups";
161163
keyName = groupName;
164+
} else if ("tool.uv.constraint-dependencies".equals(scope)) {
165+
tableName = "tool.uv";
166+
keyName = "constraint-dependencies";
167+
} else if ("tool.uv.override-dependencies".equals(scope)) {
168+
tableName = "tool.uv";
169+
keyName = "override-dependencies";
162170
} else {
163171
return false;
164172
}
@@ -241,6 +249,10 @@ public static boolean isInsideDependencyArray(Cursor cursor, @Nullable String sc
241249
}
242250
List<Dependency> deps = marker.getDependencyGroups().get(groupName);
243251
return deps != null ? findInList(deps, packageName) : null;
252+
} else if ("tool.uv.constraint-dependencies".equals(scope)) {
253+
return findInList(marker.getConstraintDependencies(), packageName);
254+
} else if ("tool.uv.override-dependencies".equals(scope)) {
255+
return findInList(marker.getOverrideDependencies(), packageName);
244256
}
245257
return null;
246258
}

rewrite-python/src/main/java/org/openrewrite/python/internal/PythonDependencyParser.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ public class PythonDependencyParser {
6262
Map<String, List<Dependency>> optionalDependencies = getOptionalDependencies(tables);
6363
Map<String, List<Dependency>> dependencyGroups = getDependencyGroups(tables);
6464

65+
Toml.Table uvTable = tables.get("tool.uv");
66+
List<Dependency> constraintDependencies = getDependencyList(uvTable, "constraint-dependencies");
67+
List<Dependency> overrideDependencies = getDependencyList(uvTable, "override-dependencies");
68+
6569
String path = doc.getSourcePath().toString();
6670

6771
return new PythonResolutionResult(
@@ -77,6 +81,8 @@ public class PythonDependencyParser {
7781
dependencies,
7882
optionalDependencies,
7983
dependencyGroups,
84+
constraintDependencies,
85+
overrideDependencies,
8086
Collections.emptyList(),
8187
null,
8288
null

0 commit comments

Comments
 (0)