Skip to content

Commit ef603c1

Browse files
Python: Pipfile support and dependency recipe rework (#7521)
* Added pipenv lockfiles * Added pip parsing * Added pip parsing * Added pip parsing * Plan: Python dependency recipe Accumulator rework * Add PyProjectHelper.regenerateLockContent dispatcher * AddDependency: move per-project state to Accumulator and run regen in generate() Refactors AddDependency to use a richer Accumulator with per-project ProjectState objects that capture lock file paths, lock content, and dependency-match flags during scanning. Dependency modification and lock regeneration now run in the visitor on the live (chain-modified) tree so that composite recipes correctly chain their modifications. Replaces ExecutionContext-based lock state with accumulator-based state, which keeps per-recipe state isolated and makes the approach composable. * AddDependency: move per-project state to Accumulator and run regen in visitor Replaces the PythonDependencyExecutionContextView-based state with per-project ProjectState entries on the Accumulator. The scanner captures lock-file content and the recipe-specific match predicate; the visitor applies the trait edit on the live tree, refreshes the marker, runs lock regeneration, and caches the result. The lock-file visit reads the cache and re-emits the regenerated content. This composes correctly across CompositeRecipe chains because each recipe's edit lands before the next recipe's edit phase begins. * AddDependency: ctx side-channel for cross-recipe deps tree sync Composite chains of dependency recipes need the lock-file branch to see the latest deps tree (with prior recipe edits applied), not the original captured by the scanner. The scanner runs on originals before any edit phase, so a B that follows A would otherwise regenerate from A's pre-edit input. Move per-source compute into the visitor's pre-visit and use an ExecutionContext side channel keyed by deps-file path: - deps-file branch: trait edit on the live cursor, write the modified tree to the ctx side channel, regenerate lock content if scanner captured one for this project. - lock-file branch: lazily compute on first visit by reading the live deps tree from ctx (or the scanner-captured fallback when no prior recipe touched this path), reapplying the trait edit through a synthetic Cursor rooted at the visitor's root cursor. This works across composite recipe chains because the side channel is ExecutionContext-scoped, and tolerates within-cycle visit-order variations because the lock branch lazy-computes whenever it visits first. * AddDependency / PyProjectHelper: post-prototype simplifications Cross-confirmed findings from /simplify review: - Drop unused ProjectState.lockFilePath (set but never read). - Drop overridden generate() that returned an empty list — it's the ScanningRecipe default. - Drop the parallel RegenerationResult value type — collapse onto LockFileRegeneration.Result, which has the same shape (success flag, lock content, error message). PyProjectHelper.regenerateLockContent now returns LockFileRegeneration.Result directly. - Move the PackageManager → adapter switch onto LockFileRegeneration itself (LockFileRegeneration.forPackageManager(pm)). PyProjectHelper no longer encodes the UV/PIPENV mapping. - Replace synthetic-cursor workaround new Cursor(getCursor().getRoot(), depsTree) with the idiomatic two-level form used by TraitMatcher.lower(SourceFile): new Cursor(new Cursor(null, Cursor.ROOT_VALUE), depsTree). Decouples the lock-branch trait match from the visitor's own cursor state. - Reuse the visitor's matcher field on the lock branch instead of allocating a fresh PythonDependencyFile.Matcher per visit. - Hoist the depsPath null-check and lockPs lookup with early returns, reducing nesting in the lock branch. - Drop narrative comments that restate obvious code. - Tighten imports in PyProjectHelper (use HashMap import, drop java.util.function.Function FQN where the import already exists). * AddDependency / PyProjectHelper: collapse regen state, mark old API deprecated Post-review cleanup before mirroring to RemoveDependency / ChangeDependency / UpgradeDependencyVersion / UpgradeTransitiveDependencyVersion: - Collapse ProjectState.regeneratedLockContent + regenerationError into a single nullable LockFileRegeneration.Result. The two fields were mutually exclusive by construction (editAndRegenerate populates one or the other on a regen attempt) — keeping them parallel rebuilt the same redundancy that the previous /simplify pass already removed from EditAndRegenerateResult. Same change applied to EditAndRegenerateResult itself: the changed(modified, regen, error) factory is now changed( modified, @nullable Result). The visitor reads ps.regenResult.isSuccess() / getLockFileContent() / getErrorMessage() directly. The ensureComputed idempotence guard simplifies to a single modifiedDepsFile null-check (the regenerationError half was dead since modifiedDepsFile is also set in the error branch). - Mark the legacy ExecutionContext-based PyProjectHelper helpers @deprecated: captureExistingLockContent, maybeReplayLockContent, maybeUpdateUvLock, maybeUpdatePipfileLock, regenerateLockAndRefreshMarker, regeneratePipfileLockAndRefreshMarker. They will be deleted in Task 8 of the Accumulator rework, but until then the @deprecated marker prevents the four sibling-recipe rewrites from accidentally reaching for them. - Sync the plan's ProjectState shape with the implementation. * Plan: sync Phase responsibilities with simplified ProjectState shape * RemoveDependency: ctx side-channel for cross-recipe deps tree sync * ChangeDependency: ctx side-channel for cross-recipe deps tree sync * Plan: fix Task 4 template (ChangeDependency has no scope/groupName) * UpgradeDependencyVersion: ctx side-channel for cross-recipe deps tree sync * UpgradeTransitiveDependencyVersion: ctx side-channel for cross-recipe deps tree sync * Remove afterModification from PythonDependencyFile trait * Drop PythonDependencyExecutionContextView and unused PyProjectHelper helpers * Remove internal planning doc * Note in dependency-recipe descriptions that they aren't safe as preconditions * 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. * TomlParserVisitor: strip quotes from quoted-key identifier name `Toml.Identifier.name` previously held the verbatim source for both bare and quoted simple keys, so `"foo"` and `foo` produced different `name` values even though the TOML spec treats them as the same key. Consumers that matched on `getName()` had to strip quotes themselves at every site. The class already has a separate `source` field for round-trip fidelity; populate `name` with the unquoted form for simple keys while keeping `source` as-is so the printer still emits the original. Dotted keys are out of scope for this change. Without a dedicated multi-segment AST type the parser cannot tell `site."google.com"` (two segments, the second containing a literal dot) apart from `site.google.com` (three segments) once both are flattened into a single `name` string, so quoted segments inside a dotted key remain unstripped for now. * Python deps recipes: handle quoted TOML keys and fix within-cycle chaining Two bugs surfaced when running the dependency recipes via the Moderne CLI against real Pipenv repositories. **Quoted keys in `[packages]` / `[tool.pdm.overrides]`** `Pipfile` and `pyproject.toml` allow quoted keys (e.g. `"flake8" = "*"`). The recipes' match logic compared the package name to `Toml.Identifier.getName()`, which now (with the rewrite-toml fix) returns the unquoted form, so the comparisons just work. Removed the ad-hoc instanceof+cast pattern from the call sites in favour of a single null-safe `PyProjectHelper.extractKeyName(KeyValue)` helper used by `PipfileFile`, `PipfileParser`, `PythonDependencyParser`, and `PyProjectFile.upgradePdmOverride`. The PDM-overrides path also silently ignored quoted keys via the same defect. **Within-cycle chaining of recipes in a composite `recipeList`** OpenRewrite's `RecipeRunCycle` runs every sub-recipe's scanner against the original tree per cycle; only the edit phase chains within a cycle. Each recipe was caching the scan-time `depsFileMatches` decision and short-circuiting the visitor on it, so a downstream recipe in a composite chain could not see additions/edits made by an earlier recipe in the same cycle — convergence stretched across multiple cycles, and lock-file regeneration ran on stale assumptions. Drop the `depsFileMatches` field and the `acc.projects.values().stream().noneMatch(...)` short-circuit in all 5 dependency recipes. Match decisions now happen at visit time against the live trait obtained from the cursor (or via a synthetic cursor for the lock-file lookahead path), so a chain like `AddDependency → UpgradeDependencyVersion` for the same package converges in a single cycle. Tests: - TOML-quoted-key coverage on all 5 recipe test classes (Pipfile and PDM-overrides cases). - New `ChangeDependencyTest.chainAddThenUpgradeAcrossRecipes` exercises the within-cycle chain via a YAML composite recipe and asserts single-cycle convergence. --------- Co-authored-by: Knut Wannheden <knut@moderne.io>
1 parent c4774bb commit ef603c1

36 files changed

Lines changed: 2132 additions & 766 deletions

rewrite-json/src/main/java/org/openrewrite/json/JsonParser.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ public Stream<SourceFile> parse(@Language("json") String... sources) {
7878

7979
@Override
8080
public boolean accept(Path path) {
81-
return path.toString().endsWith(".json");
81+
String fileName = path.getFileName().toString();
82+
return fileName.endsWith(".json") || "Pipfile.lock".equals(fileName);
8283
}
8384

8485
@Override

rewrite-python/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies {
2424
api(project(":rewrite-core"))
2525
api(project(":rewrite-java"))
2626
api(project(":rewrite-toml"))
27+
implementation(project(":rewrite-json"))
2728

2829
api("org.jetbrains:annotations:latest.release")
2930
api("com.fasterxml.jackson.core:jackson-annotations")
@@ -126,6 +127,7 @@ testing {
126127
dependencies {
127128
implementation(project())
128129
implementation(project(":rewrite-java-21"))
130+
implementation(project(":rewrite-json"))
129131
implementation(project(":rewrite-test"))
130132
implementation("org.assertj:assertj-core:latest.release")
131133
implementation("org.junit.platform:junit-platform-suite-api")

rewrite-python/src/integTest/java/org/openrewrite/python/ParseProjectIntegTest.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.junit.jupiter.api.io.TempDir;
2323
import org.openrewrite.InMemoryExecutionContext;
2424
import org.openrewrite.SourceFile;
25+
import org.openrewrite.json.tree.Json;
2526
import org.openrewrite.python.marker.PythonResolutionResult;
2627
import org.openrewrite.python.rpc.PythonRewriteRpc;
2728
import org.openrewrite.text.PlainText;
@@ -225,6 +226,47 @@ void includesRequirementsTxt() throws IOException {
225226
assertThat(reqsTxt.getMarkers().findFirst(PythonResolutionResult.class)).isPresent();
226227
}
227228

229+
@Test
230+
@Timeout(value = 60, unit = TimeUnit.SECONDS)
231+
void includesPipfile() throws IOException {
232+
Path projectDir = tempDir.resolve("with_pipfile");
233+
Files.createDirectories(projectDir);
234+
235+
Files.writeString(projectDir.resolve("main.py"), "x = 1");
236+
Files.writeString(projectDir.resolve("Pipfile"), """
237+
[packages]
238+
requests = ">=2.28.0"
239+
""");
240+
Files.writeString(projectDir.resolve("Pipfile.lock"), """
241+
{
242+
"_meta": {"sources": [{"url": "https://pypi.org/simple"}]},
243+
"default": {"requests": {"version": "==2.31.0"}},
244+
"develop": {}
245+
}
246+
""");
247+
248+
List<SourceFile> sources = client()
249+
.parseProject(projectDir, new InMemoryExecutionContext())
250+
.collect(Collectors.toList());
251+
252+
assertThat(sources)
253+
.extracting(sf -> sf.getSourcePath().getFileName().toString())
254+
.contains("main.py", "Pipfile", "Pipfile.lock");
255+
256+
SourceFile pipfile = sources.stream()
257+
.filter(sf -> sf.getSourcePath().getFileName().toString().equals("Pipfile"))
258+
.findFirst()
259+
.orElseThrow();
260+
assertThat(pipfile).isInstanceOf(Toml.Document.class);
261+
assertThat(pipfile.getMarkers().findFirst(PythonResolutionResult.class)).isPresent();
262+
263+
SourceFile pipfileLock = sources.stream()
264+
.filter(sf -> sf.getSourcePath().getFileName().toString().equals("Pipfile.lock"))
265+
.findFirst()
266+
.orElseThrow();
267+
assertThat(pipfileLock).isInstanceOf(Json.Document.class);
268+
}
269+
228270
@Test
229271
@Timeout(value = 60, unit = TimeUnit.SECONDS)
230272
void pyprojectTomlTakesPriorityOverRequirementsTxt() throws IOException {

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

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,24 @@
1919
import lombok.Value;
2020
import org.jspecify.annotations.Nullable;
2121
import org.openrewrite.*;
22+
import org.openrewrite.json.tree.Json;
23+
import org.openrewrite.marker.Markup;
24+
import org.openrewrite.python.internal.LockFileRegeneration;
2225
import org.openrewrite.python.internal.PyProjectHelper;
23-
import org.openrewrite.python.internal.PythonDependencyExecutionContextView;
2426
import org.openrewrite.python.trait.PythonDependencyFile;
2527
import org.openrewrite.toml.tree.Toml;
2628

2729
import java.nio.file.Path;
2830
import java.util.Collections;
29-
import java.util.HashSet;
31+
import java.util.HashMap;
3032
import java.util.Map;
31-
import java.util.Set;
33+
import java.util.function.Function;
3234

3335
/**
3436
* Add a dependency to a Python project. Supports {@code pyproject.toml}
3537
* (with scope and group targeting), {@code requirements.txt}, and {@code Pipfile}.
36-
* When uv is available, the uv.lock file is regenerated to reflect the change.
38+
* When the matching package manager is available on {@code PATH}, the lock file
39+
* (uv.lock for pyproject, Pipfile.lock for Pipfile) is regenerated to reflect the change.
3740
*/
3841
@EqualsAndHashCode(callSuper = false)
3942
@Value
@@ -90,11 +93,22 @@ public String getInstanceNameSuffix() {
9093
public String getDescription() {
9194
return "Add a dependency to a Python project. Supports `pyproject.toml` " +
9295
"(with scope/group targeting), `requirements.txt`, and `Pipfile`. " +
93-
"When `uv` is available, the `uv.lock` file is regenerated.";
96+
"When the matching package manager (`uv` or `pipenv`) is available, " +
97+
"the corresponding lock file (`uv.lock` or `Pipfile.lock`) is regenerated. " +
98+
"Not safe to use as a precondition: invokes the package manager and " +
99+
"publishes per-project state shared with other dependency recipes.";
94100
}
95101

96102
static class Accumulator {
97-
final Set<Path> projectsToUpdate = new HashSet<>();
103+
final Map<Path, ProjectState> projects = new HashMap<>();
104+
final Map<Path, Path> lockToDeps = new HashMap<>();
105+
}
106+
107+
static class ProjectState {
108+
@Nullable SourceFile capturedDepsFile;
109+
@Nullable String capturedLockContent;
110+
@Nullable SourceFile modifiedDepsFile;
111+
LockFileRegeneration.@Nullable Result regenResult;
98112
}
99113

100114
@Override
@@ -114,26 +128,40 @@ public Tree preVisit(Tree tree, ExecutionContext ctx) {
114128
return tree;
115129
}
116130
SourceFile sourceFile = (SourceFile) tree;
117-
if (tree instanceof Toml.Document && sourceFile.getSourcePath().endsWith("uv.lock")) {
118-
PythonDependencyExecutionContextView.view(ctx).getExistingLockContents().put(
119-
PyProjectHelper.correspondingPyprojectPath(sourceFile.getSourcePath()),
120-
((Toml.Document) tree).printAll());
131+
Path sourcePath = sourceFile.getSourcePath();
132+
133+
if (tree instanceof Toml.Document && sourcePath.endsWith("uv.lock")) {
134+
Path depsPath = PyProjectHelper.correspondingPyprojectPath(sourcePath);
135+
ProjectState ps = acc.projects.computeIfAbsent(depsPath, k -> new ProjectState());
136+
ps.capturedLockContent = ((Toml.Document) tree).printAll();
137+
acc.lockToDeps.put(sourcePath, depsPath);
138+
return tree;
139+
}
140+
if (tree instanceof Json.Document && sourcePath.endsWith("Pipfile.lock")) {
141+
Path depsPath = PyProjectHelper.correspondingPipfilePath(sourcePath);
142+
ProjectState ps = acc.projects.computeIfAbsent(depsPath, k -> new ProjectState());
143+
ps.capturedLockContent = ((Json.Document) tree).printAll();
144+
acc.lockToDeps.put(sourcePath, depsPath);
121145
return tree;
122146
}
147+
123148
PythonDependencyFile trait = matcher.get(getCursor()).orElse(null);
124-
if (trait != null && PyProjectHelper.findDependencyInScope(trait.getMarker(), packageName, scope, groupName) == null) {
125-
acc.projectsToUpdate.add(sourceFile.getSourcePath());
149+
if (trait != null) {
150+
ProjectState ps = acc.projects.computeIfAbsent(sourcePath, k -> new ProjectState());
151+
ps.capturedDepsFile = sourceFile;
126152
}
127153
return tree;
128154
}
129155
};
130156
}
131157

158+
private boolean matchesAddDependency(PythonDependencyFile trait) {
159+
return PyProjectHelper.findDependencyInScope(
160+
trait.getMarker(), packageName, scope, groupName) == null;
161+
}
162+
132163
@Override
133164
public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
134-
if (acc.projectsToUpdate.isEmpty()) {
135-
return TreeVisitor.noop();
136-
}
137165
return new TreeVisitor<Tree, ExecutionContext>() {
138166
final PythonDependencyFile.Matcher matcher = new PythonDependencyFile.Matcher();
139167

@@ -146,27 +174,74 @@ public Tree preVisit(Tree tree, ExecutionContext ctx) {
146174
SourceFile sourceFile = (SourceFile) tree;
147175
Path sourcePath = sourceFile.getSourcePath();
148176

149-
if (acc.projectsToUpdate.contains(sourcePath)) {
177+
ProjectState ps = acc.projects.get(sourcePath);
178+
if (ps != null) {
150179
PythonDependencyFile trait = matcher.get(getCursor()).orElse(null);
151-
if (trait != null) {
152-
String ver = version != null ? version : "";
153-
Map<String, String> additions = Collections.singletonMap(packageName, ver);
154-
PythonDependencyFile updated = trait.withAddedDependencies(additions, scope, groupName);
155-
if (updated.getTree() != tree) {
156-
return updated.afterModification(ctx);
180+
if (trait != null && matchesAddDependency(trait)) {
181+
ensureComputed(ps, trait);
182+
}
183+
if (ps.modifiedDepsFile != null) {
184+
SourceFile out = ps.modifiedDepsFile;
185+
if (ps.regenResult != null && !ps.regenResult.isSuccess()) {
186+
out = Markup.warn(out, new RuntimeException(
187+
"lock regeneration failed: " + ps.regenResult.getErrorMessage()));
157188
}
189+
PyProjectHelper.putLiveDepsTree(ctx, sourcePath, out);
190+
return out;
158191
}
159192
}
160193

161-
if (tree instanceof Toml.Document && sourcePath.endsWith("uv.lock")) {
162-
Toml.Document updatedLock = PyProjectHelper.maybeUpdateUvLock((Toml.Document) tree, ctx);
163-
if (updatedLock != null) {
164-
return updatedLock;
194+
Path depsPath = acc.lockToDeps.get(sourcePath);
195+
if (depsPath == null) {
196+
return tree;
197+
}
198+
ProjectState lockPs = acc.projects.get(depsPath);
199+
if (lockPs == null) {
200+
return tree;
201+
}
202+
if (lockPs.modifiedDepsFile == null) {
203+
SourceFile depsTree = PyProjectHelper.getLiveDepsTree(ctx, depsPath);
204+
if (depsTree == null) {
205+
depsTree = lockPs.capturedDepsFile;
206+
}
207+
if (depsTree != null) {
208+
Cursor synth = new Cursor(new Cursor(null, Cursor.ROOT_VALUE), depsTree);
209+
PythonDependencyFile trait = matcher.get(synth).orElse(null);
210+
if (trait != null && matchesAddDependency(trait)) {
211+
ensureComputed(lockPs, trait);
212+
if (lockPs.modifiedDepsFile != null) {
213+
PyProjectHelper.putLiveDepsTree(ctx, depsPath, lockPs.modifiedDepsFile);
214+
}
215+
}
216+
}
217+
}
218+
if (lockPs.regenResult != null && lockPs.regenResult.isSuccess()) {
219+
String lockContent = lockPs.regenResult.getLockFileContent();
220+
if (tree instanceof Toml.Document) {
221+
return PyProjectHelper.reparseToml((Toml.Document) tree, lockContent);
222+
}
223+
if (tree instanceof Json.Document) {
224+
return PyProjectHelper.reparseJson((Json.Document) tree, lockContent);
165225
}
166226
}
167-
168227
return tree;
169228
}
229+
230+
private void ensureComputed(ProjectState ps, PythonDependencyFile trait) {
231+
if (ps.modifiedDepsFile != null) {
232+
return;
233+
}
234+
String ver = version != null ? version : "";
235+
Map<String, String> additions = Collections.singletonMap(packageName, ver);
236+
Function<PythonDependencyFile, PythonDependencyFile> editFn =
237+
t -> t.withAddedDependencies(additions, scope, groupName);
238+
PyProjectHelper.EditAndRegenerateResult r =
239+
PyProjectHelper.editAndRegenerate(trait, editFn, ps.capturedLockContent);
240+
if (r.isChanged()) {
241+
ps.modifiedDepsFile = r.getModifiedDepsFile();
242+
ps.regenResult = r.getRegenResult();
243+
}
244+
}
170245
};
171246
}
172247

0 commit comments

Comments
 (0)