Skip to content

Commit e3dd286

Browse files
committed
Refresh PythonResolutionResult resolved deps after recipe edits
Recipes that edit a deps file and successfully regenerate its lock file now overlay the resolved-dependency information from the regenerated lock content onto the source file's PythonResolutionResult marker. Previously editAndRegenerate only refreshed the declared-dep half of the marker, so any subsequent recipe (or downstream consumer) reading resolved dependencies still saw the pre-edit lock state. Implementation: - Extract the applyResolution + linkResolved + linkResolvedMap helpers shared by PyProjectTomlParser and PipfileParser into a new PythonResolutionLinker utility with two entry points (applyPyproject / applyPipfile) covering the differing sets of marker fields each format exposes. - PyProjectHelper.applyResolvedDependencies(SourceFile, String) dispatches on the marker's PackageManager (Uv -> UvLockParser, Pipenv -> PipfileLockParser) to parse the lock content and run it through the linker. - editAndRegenerate calls this overlay step after a successful regen, so the modifiedDepsFile returned to recipes carries a fully up-to- date marker. - AddDependencyTest gains a marker-overlay assertion: after adding flask to a uv project, the post-recipe pyproject's marker contains flask in resolvedDependencies and the declared flask Dependency is linked to its ResolvedDependency entry. No recipe-level changes; the helper boundary absorbs the new behavior.
1 parent 27101f0 commit e3dd286

5 files changed

Lines changed: 179 additions & 74 deletions

File tree

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

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.openrewrite.SourceFile;
1212
import org.openrewrite.python.internal.LockFileRegeneration;
1313
import org.openrewrite.python.internal.PipfileLockParser;
14+
import org.openrewrite.python.internal.PythonResolutionLinker;
1415
import org.openrewrite.python.marker.PythonResolutionResult;
1516
import org.openrewrite.python.marker.PythonResolutionResult.Dependency;
1617
import org.openrewrite.python.marker.PythonResolutionResult.PackageManager;
@@ -20,7 +21,6 @@
2021

2122
import java.nio.file.Path;
2223
import java.util.*;
23-
import java.util.stream.Collectors;
2424
import java.util.stream.Stream;
2525

2626
import static org.openrewrite.Tree.randomId;
@@ -63,7 +63,7 @@ private static PythonResolutionResult resolveFromLockFile(PythonResolutionResult
6363

6464
List<ResolvedDependency> resolvedDeps = PipfileLockParser.findAndParse(pipfileDir, relativeTo);
6565
if (!resolvedDeps.isEmpty()) {
66-
return applyResolution(marker, resolvedDeps);
66+
return PythonResolutionLinker.applyPipfile(marker, resolvedDeps);
6767
}
6868

6969
// No Pipfile.lock found — try regenerating it from the Pipfile contents.
@@ -76,35 +76,7 @@ private static PythonResolutionResult resolveFromLockFile(PythonResolutionResult
7676
if (resolvedDeps.isEmpty()) {
7777
return marker;
7878
}
79-
return applyResolution(marker, resolvedDeps);
80-
}
81-
82-
private static PythonResolutionResult applyResolution(PythonResolutionResult marker,
83-
List<ResolvedDependency> resolvedDeps) {
84-
marker = marker.withResolvedDependencies(resolvedDeps);
85-
marker = marker.withDependencies(linkResolved(marker.getDependencies(), resolvedDeps));
86-
marker = marker.withOptionalDependencies(linkResolvedMap(marker.getOptionalDependencies(), resolvedDeps));
87-
return marker;
88-
}
89-
90-
private static Map<String, List<Dependency>> linkResolvedMap(Map<String, List<Dependency>> depMap,
91-
List<ResolvedDependency> resolved) {
92-
Map<String, List<Dependency>> result = new LinkedHashMap<>();
93-
for (Map.Entry<String, List<Dependency>> entry : depMap.entrySet()) {
94-
result.put(entry.getKey(), linkResolved(entry.getValue(), resolved));
95-
}
96-
return result;
97-
}
98-
99-
private static List<Dependency> linkResolved(List<Dependency> deps, List<ResolvedDependency> resolved) {
100-
return deps.stream().map(dep -> {
101-
String normalizedName = PythonResolutionResult.normalizeName(dep.getName());
102-
ResolvedDependency found = resolved.stream()
103-
.filter(r -> PythonResolutionResult.normalizeName(r.getName()).equals(normalizedName))
104-
.findFirst()
105-
.orElse(null);
106-
return found != null ? dep.withResolved(found) : dep;
107-
}).collect(Collectors.toList());
79+
return PythonResolutionLinker.applyPipfile(marker, resolvedDeps);
10880
}
10981

11082
public static @Nullable PythonResolutionResult createMarker(Toml.Document doc) {

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

Lines changed: 4 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,18 @@
1919
import org.openrewrite.ExecutionContext;
2020
import org.openrewrite.Parser;
2121
import org.openrewrite.SourceFile;
22+
import org.openrewrite.python.internal.LockFileRegeneration;
2223
import org.openrewrite.python.internal.PythonDependencyParser;
24+
import org.openrewrite.python.internal.PythonResolutionLinker;
2325
import org.openrewrite.python.internal.UvLockParser;
24-
import org.openrewrite.python.internal.LockFileRegeneration;
2526
import org.openrewrite.python.marker.PythonResolutionResult;
26-
import org.openrewrite.python.marker.PythonResolutionResult.Dependency;
2727
import org.openrewrite.python.marker.PythonResolutionResult.PackageManager;
2828
import org.openrewrite.python.marker.PythonResolutionResult.ResolvedDependency;
2929
import org.openrewrite.toml.TomlParser;
3030
import org.openrewrite.toml.tree.Toml;
3131

3232
import java.nio.file.Path;
33-
import java.util.LinkedHashMap;
3433
import java.util.List;
35-
import java.util.Map;
36-
import java.util.stream.Collectors;
3734
import java.util.stream.Stream;
3835

3936
/**
@@ -80,7 +77,7 @@ private PythonResolutionResult resolveFromLockFile(PythonResolutionResult marker
8077

8178
List<ResolvedDependency> resolvedDeps = UvLockParser.findAndParse(pyprojectDir, relativeTo);
8279
if (!resolvedDeps.isEmpty()) {
83-
return applyResolution(marker, resolvedDeps);
80+
return PythonResolutionLinker.applyPyproject(marker, resolvedDeps);
8481
}
8582

8683
// No uv.lock found — check if another package manager owns this project
@@ -103,43 +100,7 @@ private PythonResolutionResult resolveFromLockFile(PythonResolutionResult marker
103100
return marker;
104101
}
105102

106-
return applyResolution(marker, resolvedDeps);
107-
}
108-
109-
private PythonResolutionResult applyResolution(PythonResolutionResult marker,
110-
List<ResolvedDependency> resolvedDeps) {
111-
marker = marker.withResolvedDependencies(resolvedDeps);
112-
marker = marker.withPackageManager(PackageManager.Uv);
113-
114-
// Link declared dependencies to their resolved versions
115-
marker = marker.withDependencies(linkResolved(marker.getDependencies(), resolvedDeps));
116-
marker = marker.withBuildRequires(linkResolved(marker.getBuildRequires(), resolvedDeps));
117-
marker = marker.withOptionalDependencies(linkResolvedMap(marker.getOptionalDependencies(), resolvedDeps));
118-
marker = marker.withDependencyGroups(linkResolvedMap(marker.getDependencyGroups(), resolvedDeps));
119-
marker = marker.withConstraintDependencies(linkResolved(marker.getConstraintDependencies(), resolvedDeps));
120-
marker = marker.withOverrideDependencies(linkResolved(marker.getOverrideDependencies(), resolvedDeps));
121-
122-
return marker;
123-
}
124-
125-
private Map<String, List<Dependency>> linkResolvedMap(Map<String, List<Dependency>> depMap,
126-
List<ResolvedDependency> resolved) {
127-
Map<String, List<Dependency>> result = new LinkedHashMap<>();
128-
for (Map.Entry<String, List<Dependency>> entry : depMap.entrySet()) {
129-
result.put(entry.getKey(), linkResolved(entry.getValue(), resolved));
130-
}
131-
return result;
132-
}
133-
134-
private List<Dependency> linkResolved(List<Dependency> deps, List<ResolvedDependency> resolved) {
135-
return deps.stream().map(dep -> {
136-
String normalizedName = PythonResolutionResult.normalizeName(dep.getName());
137-
ResolvedDependency found = resolved.stream()
138-
.filter(r -> PythonResolutionResult.normalizeName(r.getName()).equals(normalizedName))
139-
.findFirst()
140-
.orElse(null);
141-
return found != null ? dep.withResolved(found) : dep;
142-
}).collect(Collectors.toList());
103+
return PythonResolutionLinker.applyPyproject(marker, resolvedDeps);
143104
}
144105

145106
@Override

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,43 @@ public static SourceFile refreshMarker(SourceFile depsFile) {
162162
return depsFile;
163163
}
164164

165+
/**
166+
* Overlay resolved-dependency information from regenerated lock content onto
167+
* the source file's existing {@link PythonResolutionResult} marker. Dispatches
168+
* on the marker's package manager (uv → {@link UvLockParser}; pipenv →
169+
* {@link PipfileLockParser}). Returns the source file unchanged if there is no
170+
* marker, no recognised package manager, or the lock content has no resolved
171+
* dependencies.
172+
*/
173+
public static SourceFile applyResolvedDependencies(SourceFile depsFile, String regeneratedLockContent) {
174+
PythonResolutionResult existing = depsFile.getMarkers()
175+
.findFirst(PythonResolutionResult.class).orElse(null);
176+
if (existing == null || existing.getPackageManager() == null) {
177+
return depsFile;
178+
}
179+
List<PythonResolutionResult.ResolvedDependency> resolved;
180+
PythonResolutionResult overlaid;
181+
switch (existing.getPackageManager()) {
182+
case Uv:
183+
resolved = UvLockParser.parse(regeneratedLockContent);
184+
if (resolved.isEmpty()) {
185+
return depsFile;
186+
}
187+
overlaid = PythonResolutionLinker.applyPyproject(existing, resolved);
188+
break;
189+
case Pipenv:
190+
resolved = PipfileLockParser.parse(regeneratedLockContent);
191+
if (resolved.isEmpty()) {
192+
return depsFile;
193+
}
194+
overlaid = PythonResolutionLinker.applyPipfile(existing, resolved);
195+
break;
196+
default:
197+
return depsFile;
198+
}
199+
return depsFile.withMarkers(depsFile.getMarkers().setByType(overlaid.withId(existing.getId())));
200+
}
201+
165202
/**
166203
* ExecutionContext key for the {@code Map<Path, SourceFile>} that holds the
167204
* latest chain-modified deps tree for each project path. Recipes write to this
@@ -219,6 +256,9 @@ public static EditAndRegenerateResult editAndRegenerate(
219256
SourceFile modified = refreshMarker((SourceFile) updated.getTree());
220257
LockFileRegeneration.Result regen = capturedLockContent == null ? null
221258
: regenerateLockContent(modified, capturedLockContent);
259+
if (regen != null && regen.isSuccess() && regen.getLockFileContent() != null) {
260+
modified = applyResolvedDependencies(modified, regen.getLockFileContent());
261+
}
222262
return EditAndRegenerateResult.changed(modified, regen);
223263
}
224264

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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.internal;
17+
18+
import org.openrewrite.python.marker.PythonResolutionResult;
19+
import org.openrewrite.python.marker.PythonResolutionResult.Dependency;
20+
import org.openrewrite.python.marker.PythonResolutionResult.PackageManager;
21+
import org.openrewrite.python.marker.PythonResolutionResult.ResolvedDependency;
22+
23+
import java.util.LinkedHashMap;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.stream.Collectors;
27+
28+
/**
29+
* Overlays resolved-dependency information from a parsed lock file onto a
30+
* {@link PythonResolutionResult} marker. Pyproject and Pipfile have different
31+
* sets of declared-dependency fields, so two entry points are exposed.
32+
*/
33+
public final class PythonResolutionLinker {
34+
35+
private PythonResolutionLinker() {
36+
}
37+
38+
/**
39+
* Apply pyproject-shaped resolution: link dependencies, build-requires,
40+
* optional-dependencies, dependency-groups, constraint-dependencies, and
41+
* override-dependencies. Sets the package manager to {@link PackageManager#Uv}
42+
* since uv is the resolver this overlay covers.
43+
*/
44+
public static PythonResolutionResult applyPyproject(PythonResolutionResult marker,
45+
List<ResolvedDependency> resolvedDeps) {
46+
marker = marker.withResolvedDependencies(resolvedDeps);
47+
marker = marker.withPackageManager(PackageManager.Uv);
48+
marker = marker.withDependencies(link(marker.getDependencies(), resolvedDeps));
49+
marker = marker.withBuildRequires(link(marker.getBuildRequires(), resolvedDeps));
50+
marker = marker.withOptionalDependencies(linkMap(marker.getOptionalDependencies(), resolvedDeps));
51+
marker = marker.withDependencyGroups(linkMap(marker.getDependencyGroups(), resolvedDeps));
52+
marker = marker.withConstraintDependencies(link(marker.getConstraintDependencies(), resolvedDeps));
53+
marker = marker.withOverrideDependencies(link(marker.getOverrideDependencies(), resolvedDeps));
54+
return marker;
55+
}
56+
57+
/**
58+
* Apply pipfile-shaped resolution: link {@code [packages]} and
59+
* {@code [dev-packages]}. The package manager is left unchanged ({@code createMarker}
60+
* already sets it to {@link PackageManager#Pipenv}).
61+
*/
62+
public static PythonResolutionResult applyPipfile(PythonResolutionResult marker,
63+
List<ResolvedDependency> resolvedDeps) {
64+
marker = marker.withResolvedDependencies(resolvedDeps);
65+
marker = marker.withDependencies(link(marker.getDependencies(), resolvedDeps));
66+
marker = marker.withOptionalDependencies(linkMap(marker.getOptionalDependencies(), resolvedDeps));
67+
return marker;
68+
}
69+
70+
public static List<Dependency> link(List<Dependency> deps, List<ResolvedDependency> resolved) {
71+
return deps.stream().map(dep -> {
72+
String normalizedName = PythonResolutionResult.normalizeName(dep.getName());
73+
ResolvedDependency found = resolved.stream()
74+
.filter(r -> PythonResolutionResult.normalizeName(r.getName()).equals(normalizedName))
75+
.findFirst()
76+
.orElse(null);
77+
return found != null ? dep.withResolved(found) : dep;
78+
}).collect(Collectors.toList());
79+
}
80+
81+
public static Map<String, List<Dependency>> linkMap(Map<String, List<Dependency>> depMap,
82+
List<ResolvedDependency> resolved) {
83+
Map<String, List<Dependency>> result = new LinkedHashMap<>();
84+
for (Map.Entry<String, List<Dependency>> entry : depMap.entrySet()) {
85+
result.put(entry.getKey(), link(entry.getValue(), resolved));
86+
}
87+
return result;
88+
}
89+
}

rewrite-python/src/test/java/org/openrewrite/python/AddDependencyTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.junit.jupiter.api.Test;
1919
import org.junit.jupiter.api.io.TempDir;
2020
import org.openrewrite.config.CompositeRecipe;
21+
import org.openrewrite.python.marker.PythonResolutionResult;
2122
import org.openrewrite.test.RewriteTest;
2223

2324
import java.nio.file.Path;
@@ -297,6 +298,48 @@ void skipWhenAlreadyInScope() {
297298
);
298299
}
299300

301+
@Test
302+
void markerResolvedDependenciesUpdatedAfterEdit(@TempDir Path tempDir) {
303+
rewriteRun(
304+
spec -> spec.recipe(new AddDependency("flask", ">=2.0", null, null)),
305+
uv(tempDir,
306+
pyproject(
307+
"""
308+
[project]
309+
name = "myapp"
310+
version = "1.0.0"
311+
dependencies = [
312+
"requests>=2.28.0",
313+
]
314+
""",
315+
"""
316+
[project]
317+
name = "myapp"
318+
version = "1.0.0"
319+
dependencies = [
320+
"requests>=2.28.0",
321+
"flask>=2.0",
322+
]
323+
""",
324+
s -> s.afterRecipe(doc -> {
325+
PythonResolutionResult marker = doc.getMarkers()
326+
.findFirst(PythonResolutionResult.class).orElseThrow();
327+
assertThat(marker.getResolvedDependencies())
328+
.extracting(d -> PythonResolutionResult.normalizeName(d.getName()))
329+
.as("regenerated uv.lock should contain flask among resolved dependencies")
330+
.contains("flask");
331+
assertThat(marker.getDependencies())
332+
.filteredOn(d -> "flask".equals(PythonResolutionResult.normalizeName(d.getName())))
333+
.singleElement()
334+
.satisfies(d -> assertThat(d.getResolved())
335+
.as("declared `flask` dep should be linked to its resolved entry")
336+
.isNotNull());
337+
})
338+
)
339+
)
340+
);
341+
}
342+
300343
@Test
301344
void uvLockRegenerationWorks() {
302345
String pyprojectWithFlask = """

0 commit comments

Comments
 (0)