Skip to content

Commit 2218766

Browse files
authored
Python: Add RPC infrastructure for calling Java recipes (#6677)
* Python: Add RPC infrastructure for calling Java recipes Enable Python recipes to delegate to Java recipes via JSON-RPC: - JavaRewriteRpc: Standalone RPC server that Python spawns as subprocess - Python RPC client: JavaRpcClient manages the Java subprocess lifecycle - Recipe wrappers: ChangeType, ChangeMethodName, ChangePackage, etc. - Precondition helpers: uses_type, uses_method, find_types, find_methods - Markup markers: MarkupWarn, MarkupError, MarkupInfo, MarkupDebug - Additional MarkerPrinter variants: VERBOSE, FENCED, SANITIZED * Update build.gradle.kts
1 parent d5aaf49 commit 2218766

22 files changed

Lines changed: 2246 additions & 6 deletions

rewrite-maven/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dependencies {
1111
api("com.fasterxml.jackson.core:jackson-annotations")
1212

1313
implementation(project(":rewrite-core"))
14+
implementation("io.moderne:jsonrpc:latest.release")
1415
compileOnly(project(":rewrite-test"))
1516

1617
// Caffeine 2.x works with Java 8, Caffeine 3.x is Java 11 only.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
/*
2+
* Copyright 2025 the original author or authors.
3+
* <p>
4+
* Licensed under the Apache License, Version 2.0 (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://www.apache.org/licenses/LICENSE-2.0
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.maven.rpc;
17+
18+
import com.fasterxml.jackson.core.JsonGenerator;
19+
import com.fasterxml.jackson.core.JsonParser;
20+
import com.fasterxml.jackson.databind.DeserializationContext;
21+
import com.fasterxml.jackson.databind.JsonDeserializer;
22+
import com.fasterxml.jackson.databind.JsonSerializer;
23+
import com.fasterxml.jackson.databind.SerializerProvider;
24+
import com.fasterxml.jackson.databind.module.SimpleModule;
25+
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
26+
import io.moderne.jsonrpc.JsonRpc;
27+
import io.moderne.jsonrpc.formatter.JsonMessageFormatter;
28+
import io.moderne.jsonrpc.handler.HeaderDelimitedMessageHandler;
29+
import io.moderne.jsonrpc.handler.MessageHandler;
30+
import io.moderne.jsonrpc.handler.TraceMessageHandler;
31+
import org.jspecify.annotations.Nullable;
32+
import org.openrewrite.HttpSenderExecutionContextView;
33+
import org.openrewrite.InMemoryExecutionContext;
34+
import org.openrewrite.ipc.http.HttpUrlConnectionSender;
35+
import org.openrewrite.marketplace.RecipeBundleResolver;
36+
import org.openrewrite.marketplace.RecipeClassLoader;
37+
import org.openrewrite.marketplace.RecipeMarketplace;
38+
import org.openrewrite.marketplace.RecipeMarketplaceReader;
39+
import org.openrewrite.maven.MavenExecutionContextView;
40+
import org.openrewrite.maven.cache.LocalMavenArtifactCache;
41+
import org.openrewrite.maven.marketplace.MavenRecipeBundleResolver;
42+
import org.openrewrite.maven.utilities.MavenArtifactDownloader;
43+
import org.openrewrite.rpc.RewriteRpc;
44+
45+
import java.io.IOException;
46+
import java.io.PrintStream;
47+
import java.nio.file.Files;
48+
import java.nio.file.Path;
49+
import java.nio.file.Paths;
50+
import java.nio.file.StandardOpenOption;
51+
import java.util.ArrayList;
52+
import java.util.List;
53+
54+
/**
55+
* A standalone RPC server application that communicates via stdin/stdout.
56+
* <p>
57+
* This application is designed to be spawned as a subprocess by Python, JavaScript,
58+
* or any other language that needs to use Java-based OpenRewrite recipes.
59+
* <p>
60+
* Usage:
61+
* <pre>
62+
* java -cp &lt;classpath&gt; org.openrewrite.maven.rpc.JavaRewriteRpc [options]
63+
* </pre>
64+
* <p>
65+
* Options:
66+
* <ul>
67+
* <li>--marketplace=&lt;path&gt; - Path to marketplace CSV file (required)</li>
68+
* <li>--log-file=&lt;path&gt; - Log file for debugging</li>
69+
* <li>--trace - Enable RPC message tracing</li>
70+
* </ul>
71+
* <p>
72+
* The marketplace CSV must contain columns for recipe metadata and bundle information.
73+
* Required columns: name, ecosystem, packageName, version
74+
* <p>
75+
* Example marketplace.csv:
76+
* <pre>
77+
* name,displayName,ecosystem,packageName,version
78+
* org.openrewrite.java.ChangeType,Change type,maven,org.openrewrite:rewrite-java,8.73.0
79+
* </pre>
80+
*/
81+
public class JavaRewriteRpc {
82+
83+
public static void main(String[] args) {
84+
@Nullable Path marketplaceCsv = null;
85+
@Nullable Path logFile = null;
86+
boolean trace = false;
87+
88+
// Parse command line arguments
89+
for (String arg : args) {
90+
if (arg.startsWith("--marketplace=")) {
91+
marketplaceCsv = Paths.get(arg.substring("--marketplace=".length()));
92+
} else if (arg.startsWith("--log-file=")) {
93+
logFile = Paths.get(arg.substring("--log-file=".length()));
94+
} else if (arg.equals("--trace")) {
95+
trace = true;
96+
}
97+
}
98+
99+
if (marketplaceCsv == null) {
100+
System.err.println("Error: --marketplace=<path> is required");
101+
System.err.println("Usage: java org.openrewrite.maven.rpc.JavaRewriteRpc --marketplace=<csv> [--log-file=<path>] [--trace]");
102+
System.exit(1);
103+
}
104+
105+
if (!Files.exists(marketplaceCsv)) {
106+
System.err.println("Error: Marketplace CSV file not found: " + marketplaceCsv);
107+
System.exit(1);
108+
}
109+
110+
// Redirect stderr to log file if specified (to avoid interfering with RPC on stdout)
111+
PrintStream logStream = null;
112+
if (logFile != null) {
113+
try {
114+
logStream = new PrintStream(Files.newOutputStream(logFile,
115+
StandardOpenOption.CREATE, StandardOpenOption.APPEND));
116+
System.setErr(logStream);
117+
} catch (IOException e) {
118+
System.err.println("Failed to open log file: " + e.getMessage());
119+
}
120+
}
121+
122+
try {
123+
run(marketplaceCsv, trace, logStream);
124+
} catch (Exception e) {
125+
System.err.println("RPC server error: " + e.getMessage());
126+
e.printStackTrace(System.err);
127+
System.exit(1);
128+
} finally {
129+
if (logStream != null) {
130+
logStream.close();
131+
}
132+
}
133+
}
134+
135+
private static void run(Path marketplaceCsv, boolean trace, @Nullable PrintStream logStream) {
136+
// Load the recipe marketplace from CSV
137+
RecipeMarketplaceReader reader = new RecipeMarketplaceReader();
138+
RecipeMarketplace marketplace = reader.fromCsv(marketplaceCsv);
139+
140+
if (logStream != null) {
141+
logStream.println("Loaded marketplace with " + marketplace.getAllRecipes().size() + " recipes from " + marketplaceCsv);
142+
logStream.flush();
143+
}
144+
145+
// Set up execution context
146+
PrintStream errorHandler = logStream != null ? logStream : System.err;
147+
InMemoryExecutionContext ctx = new InMemoryExecutionContext(t -> {
148+
errorHandler.println("Error: " + t.getMessage());
149+
t.printStackTrace(errorHandler);
150+
});
151+
152+
// Configure HTTP and Maven settings
153+
HttpSenderExecutionContextView.view(ctx).setHttpSender(new HttpUrlConnectionSender());
154+
MavenExecutionContextView mavenCtx = MavenExecutionContextView.view(ctx);
155+
mavenCtx.setAddCentralRepository(true);
156+
157+
// Create artifact cache in user's .rewrite directory
158+
Path cacheDir;
159+
try {
160+
cacheDir = Paths.get(System.getProperty("user.home"), ".rewrite", "cache", "artifacts");
161+
Files.createDirectories(cacheDir);
162+
} catch (IOException e) {
163+
throw new RuntimeException("Failed to create Maven artifact cache directory", e);
164+
}
165+
LocalMavenArtifactCache artifactCache = new LocalMavenArtifactCache(cacheDir);
166+
167+
MavenArtifactDownloader downloader = new MavenArtifactDownloader(
168+
artifactCache,
169+
null, // No custom Maven settings
170+
new HttpUrlConnectionSender(),
171+
t -> errorHandler.println("Download error: " + t.getMessage())
172+
);
173+
174+
// Set up resolvers
175+
List<RecipeBundleResolver> resolvers = new ArrayList<>();
176+
resolvers.add(new MavenRecipeBundleResolver(ctx, downloader, RecipeClassLoader::new));
177+
178+
if (logStream != null) {
179+
logStream.println("Configured Maven recipe bundle resolver");
180+
logStream.flush();
181+
}
182+
183+
// Set up JSON-RPC communication on stdin/stdout
184+
SimpleModule module = new SimpleModule();
185+
module.addSerializer(Path.class, new PathSerializer());
186+
module.addDeserializer(Path.class, new PathDeserializer());
187+
188+
JsonMessageFormatter formatter = new JsonMessageFormatter(module, new ParameterNamesModule());
189+
MessageHandler handler = new HeaderDelimitedMessageHandler(formatter, System.in, System.out);
190+
191+
if (trace) {
192+
handler = new TraceMessageHandler("server", handler);
193+
}
194+
195+
JsonRpc jsonRpc = new JsonRpc(handler);
196+
197+
// Create the RPC server with the marketplace and resolvers
198+
RewriteRpc server = new RewriteRpc(jsonRpc, marketplace, resolvers);
199+
200+
if (logStream != null) {
201+
server.log(logStream);
202+
logStream.println("RPC server started");
203+
logStream.flush();
204+
}
205+
206+
// The JsonRpc.bind() call in RewriteRpc constructor starts the message handler
207+
// that reads from stdin in a background thread.
208+
209+
// Keep the main thread alive while the RPC handler processes messages.
210+
// Use Object.wait() to block indefinitely - the process will be terminated
211+
// externally when the parent process closes stdin or kills this process.
212+
Object lock = new Object();
213+
synchronized (lock) {
214+
try {
215+
// Wait indefinitely - this keeps the JVM alive
216+
lock.wait();
217+
} catch (InterruptedException e) {
218+
Thread.currentThread().interrupt();
219+
if (logStream != null) {
220+
logStream.println("Server interrupted");
221+
logStream.flush();
222+
}
223+
}
224+
}
225+
226+
server.shutdown();
227+
}
228+
229+
private static class PathSerializer extends JsonSerializer<Path> {
230+
@Override
231+
public void serialize(Path path, JsonGenerator g, SerializerProvider serializerProvider) throws IOException {
232+
g.writeString(path.toString());
233+
}
234+
}
235+
236+
private static class PathDeserializer extends JsonDeserializer<Path> {
237+
@Override
238+
public Path deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
239+
String pathString = p.getValueAsString();
240+
return Paths.get(pathString);
241+
}
242+
}
243+
}

rewrite-python/build.gradle.kts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ dependencies {
1818
api("com.fasterxml.jackson.core:jackson-annotations")
1919

2020
implementation("io.moderne:jsonrpc:latest.integration")
21+
implementation(project(":rewrite-maven"))
2122

2223
compileOnly(project(":rewrite-test"))
2324

@@ -262,3 +263,53 @@ val pythonPublish by tasks.registering(Exec::class) {
262263
tasks.named("publish") {
263264
dependsOn(pythonPublish)
264265
}
266+
267+
// ============================================
268+
// Python Test Support Tasks
269+
// ============================================
270+
271+
// Task to generate classpath file for Java RPC server testing
272+
val generateTestClasspath by tasks.registering {
273+
group = "python"
274+
description = "Generate classpath file for Java RPC server (used by Python tests)"
275+
276+
val outputFile = pythonDir.resolve("test-classpath.txt")
277+
outputs.file(outputFile)
278+
279+
// Depend on jar tasks to ensure jars exist
280+
dependsOn(tasks.named("testClasses"))
281+
dependsOn(tasks.named("jar"))
282+
283+
doLast {
284+
// Combine compile and test runtime classpaths to get all dependencies
285+
val classpath = (
286+
configurations.getByName("runtimeClasspath").files +
287+
configurations.getByName("testRuntimeClasspath").files +
288+
tasks.named("compileJava").get().outputs.files +
289+
tasks.named("processResources").get().outputs.files
290+
).distinctBy { it.absolutePath }
291+
.joinToString(File.pathSeparator) { it.absolutePath }
292+
outputFile.writeText(classpath)
293+
logger.lifecycle("Generated test classpath to ${outputFile.absolutePath}")
294+
}
295+
}
296+
297+
// Task to print test classpath to stdout (useful for setting env vars)
298+
val printTestClasspath by tasks.registering {
299+
group = "python"
300+
description = "Print the test classpath (for use with REWRITE_PYTHON_CLASSPATH env var)"
301+
302+
dependsOn(tasks.named("testClasses"))
303+
304+
doLast {
305+
val classpath = (
306+
configurations.getByName("runtimeClasspath").files +
307+
configurations.getByName("testRuntimeClasspath").files +
308+
tasks.named("compileJava").get().outputs.files +
309+
tasks.named("processResources").get().outputs.files
310+
).distinctBy { it.absolutePath }
311+
.joinToString(File.pathSeparator) { it.absolutePath }
312+
println(classpath)
313+
}
314+
}
315+

rewrite-python/rewrite/src/rewrite/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@
7474
'Marker',
7575
'Markers',
7676
'SearchResult',
77+
'Markup',
78+
'MarkupWarn',
79+
'MarkupError',
80+
'MarkupInfo',
81+
'MarkupDebug',
7782
'UnknownJavaMarker',
7883
'ParseExceptionResult',
7984

rewrite-python/rewrite/src/rewrite/java/visitor.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,21 @@ def visit_container(
9494
if container is None:
9595
return None
9696
before = self.visit_space(container.before, p)
97+
original_elements = container.padding.elements
9798
elements = list_map(
9899
lambda e: self.visit_right_padded(e, p),
99-
container.padding.elements
100+
original_elements
100101
)
102+
# Track if list_map detected any changes (returns same list if unchanged)
103+
elements_changed = elements is not original_elements
101104
# Filter out None elements (deleted by visitor)
102-
elements = [e for e in elements if e is not None]
103-
if before is container.before and elements == list(container.padding.elements):
105+
filtered_elements = [e for e in elements if e is not None]
106+
# Check if filtering removed any elements
107+
if len(filtered_elements) != len(elements):
108+
elements_changed = True
109+
if before is container.before and not elements_changed:
104110
return container
105-
return container.replace(before=before).padding.replace(elements=elements)
111+
return container.replace(before=before).padding.replace(elements=filtered_elements)
106112

107113
# -------------------------------------------------------------------------
108114
# Java tree visitor methods

0 commit comments

Comments
 (0)