Skip to content

Commit 77679bc

Browse files
Python: Pin openrewrite Python package version in RPC bootstrap (#6813)
* Pin openrewrite Python package version in PythonRewriteRpc bootstrap PythonRewriteRpc.bootstrapOpenrewrite() was running bare `pip install openrewrite` without a version specifier, unlike the JavaScript side which reads /META-INF/version.txt and pins `@openrewrite/rewrite@<version>`. This meant the Python RPC could end up with a mismatched openrewrite package version, and the existence-only check meant users could get stuck on a stale version indefinitely. Changes: - Generate META-INF/version.txt in the build (PEP 440 format) so the Java code can read the expected Python package version at runtime - Pin `openrewrite==<version>` in bootstrapOpenrewrite() for release and CI builds; skip pinning for local .dev0 builds - Track installed version via a marker file to detect and upgrade stale installs - Add license exclude for version.txt and .gitignore entry * Add --upgrade to pip install so stale versions get replaced Without --upgrade, pip install --target may skip the install when it sees the package already exists, even if the version differs. * Skip bootstrap when dev interpreter already has rewrite package For SNAPSHOT/dev builds, probe whether the Python interpreter can already import rewrite (e.g., from a venv with an editable install). If so, skip both the bootstrap and the PYTHONPATH prepend so the interpreter's own package takes precedence. For release/CI builds, always bootstrap and prepend to ensure the correct pinned version is used. This mirrors the JS pattern where SNAPSHOT trusts the local dev environment (npm link) while release pins the exact version.
1 parent 890f876 commit 77679bc

3 files changed

Lines changed: 96 additions & 9 deletions

File tree

rewrite-python/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
src/main/resources/META-INF/version.txt

rewrite-python/build.gradle.kts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@file:Suppress("UnstableApiUsage")
22

3+
import nl.javadude.gradle.plugins.license.LicenseExtension
34
import java.time.LocalDateTime
45
import java.time.format.DateTimeFormatter
56

@@ -196,7 +197,7 @@ tasks.withType<Test> {
196197
// This is separate from Gradle because IntelliJ's Gradle integration doesn't support Python source roots.
197198

198199
// ============================================
199-
// Python Publishing Tasks (PyPI)
200+
// Version Resource (for RPC version pinning)
200201
// ============================================
201202

202203
// Generate a PEP 440 compliant version for CI builds
@@ -211,6 +212,31 @@ val pythonVersion: String = if (System.getenv("CI") != null) {
211212
project.version.toString().replace("-SNAPSHOT", ".dev0")
212213
}
213214

215+
// Write version.txt resource so PythonRewriteRpc can pin the pip package version
216+
val generateVersionTxt by tasks.registering {
217+
group = "python"
218+
description = "Generate META-INF/version.txt for RPC version pinning"
219+
220+
val versionTxt = file("src/main/resources/META-INF/version.txt")
221+
inputs.property("version", pythonVersion)
222+
outputs.file(versionTxt)
223+
224+
doLast {
225+
versionTxt.parentFile.mkdirs()
226+
versionTxt.writeText(pythonVersion)
227+
}
228+
}
229+
230+
listOf("sourcesJar", "processResources", "licenseMain", "assemble").forEach {
231+
tasks.named(it) {
232+
dependsOn(generateVersionTxt)
233+
}
234+
}
235+
236+
// ============================================
237+
// Python Publishing Tasks (PyPI)
238+
// ============================================
239+
214240
// Task to update version in pyproject.toml
215241
val pythonUpdateVersion by tasks.registering {
216242
group = "python"
@@ -364,3 +390,7 @@ val printTestClasspath by tasks.registering {
364390
}
365391
}
366392

393+
extensions.configure<LicenseExtension> {
394+
exclude("**/version.txt")
395+
}
396+

rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import lombok.RequiredArgsConstructor;
2020
import org.jspecify.annotations.Nullable;
2121
import org.openrewrite.*;
22+
import org.openrewrite.internal.StringUtils;
2223
import org.openrewrite.marketplace.RecipeBundleResolver;
2324
import org.openrewrite.marketplace.RecipeMarketplace;
2425
import org.openrewrite.python.*;
@@ -519,8 +520,18 @@ public Builder pythonVersion(String pythonVersion) {
519520

520521
@Override
521522
public PythonRewriteRpc get() {
522-
// Bootstrap openrewrite package if pip packages path is set
523-
if (pipPackagesPath != null) {
523+
// For dev builds (version ending in .dev0), check whether the interpreter
524+
// already has the rewrite package (e.g., from a venv with an editable install).
525+
// If so, skip bootstrap and PYTHONPATH prepend so the interpreter's own
526+
// version takes precedence. For release/CI builds, always use pipPackagesPath
527+
// to ensure the correct pinned version.
528+
String version = StringUtils.readFully(
529+
PythonRewriteRpc.class.getResourceAsStream("/META-INF/version.txt"));
530+
boolean isDevBuild = version.isEmpty() || version.endsWith(".dev0");
531+
boolean interpreterHasRewrite = isDevBuild && pipPackagesPath != null && canImportRewrite(pythonPath);
532+
boolean usePipPackagesPath = pipPackagesPath != null && !interpreterHasRewrite;
533+
534+
if (usePipPackagesPath) {
524535
bootstrapOpenrewrite(pipPackagesPath);
525536
}
526537

@@ -550,8 +561,8 @@ public PythonRewriteRpc get() {
550561
// Set up PYTHONPATH for the rewrite package
551562
List<String> pythonPathParts = new ArrayList<>();
552563

553-
// Add pip packages path first if set (takes priority)
554-
if (pipPackagesPath != null) {
564+
// Add pip packages path if the interpreter doesn't already have rewrite
565+
if (usePipPackagesPath) {
555566
pythonPathParts.add(pipPackagesPath.toAbsolutePath().normalize().toString());
556567
}
557568

@@ -602,24 +613,64 @@ public PythonRewriteRpc get() {
602613
}
603614
}
604615

616+
/**
617+
* Checks whether the given Python interpreter can already import the rewrite package
618+
* without any PYTHONPATH modifications. This detects venvs or system installs that
619+
* already have the openrewrite package available.
620+
*/
621+
private static boolean canImportRewrite(Path pythonPath) {
622+
try {
623+
Process probe = new ProcessBuilder(
624+
pythonPath.toString(), "-c", "import rewrite"
625+
).redirectErrorStream(true).start();
626+
// Drain output to prevent blocking
627+
try (InputStream is = probe.getInputStream()) {
628+
//noinspection StatementWithEmptyBody
629+
while (is.read() != -1) {
630+
}
631+
}
632+
return probe.waitFor(10, TimeUnit.SECONDS) && probe.exitValue() == 0;
633+
} catch (IOException | InterruptedException e) {
634+
return false;
635+
}
636+
}
637+
605638
/**
606639
* Ensures the openrewrite Python package is installed in the pip packages directory.
607640
* This is required for the RPC server to start.
608641
*/
609642
private void bootstrapOpenrewrite(Path pipPackagesPath) {
610-
Path rewriteModule = pipPackagesPath.resolve("rewrite");
611-
if (Files.exists(rewriteModule)) {
612-
return; // Already installed
643+
String version = StringUtils.readFully(
644+
PythonRewriteRpc.class.getResourceAsStream("/META-INF/version.txt"));
645+
boolean pinVersion = !version.isEmpty() && !version.endsWith(".dev0");
646+
647+
Path versionMarker = pipPackagesPath.resolve(".openrewrite-version");
648+
if (Files.exists(pipPackagesPath.resolve("rewrite"))) {
649+
// Already installed — check if version matches
650+
if (!pinVersion) {
651+
return;
652+
}
653+
try {
654+
if (Files.exists(versionMarker) &&
655+
version.equals(new String(Files.readAllBytes(versionMarker), StandardCharsets.UTF_8).trim())) {
656+
return; // Correct version already installed
657+
}
658+
} catch (IOException ignored) {
659+
// Can't read marker, reinstall to be safe
660+
}
613661
}
614662

663+
String packageSpec = pinVersion ? "openrewrite==" + version : "openrewrite";
664+
615665
try {
616666
Files.createDirectories(pipPackagesPath);
617667

618668
ProcessBuilder pb = new ProcessBuilder(
619669
pythonPath.toString(),
620670
"-m", "pip", "install",
671+
"--upgrade",
621672
"--target=" + pipPackagesPath.toAbsolutePath().normalize(),
622-
"openrewrite"
673+
packageSpec
623674
);
624675
pb.redirectErrorStream(true);
625676
if (log != null) {
@@ -652,6 +703,11 @@ private void bootstrapOpenrewrite(Path pipPackagesPath) {
652703
if (exitCode != 0) {
653704
throw new RuntimeException("Failed to bootstrap openrewrite package, pip install exited with code " + exitCode);
654705
}
706+
707+
// Write version marker so we can detect stale installs
708+
if (pinVersion) {
709+
Files.write(versionMarker, version.getBytes(StandardCharsets.UTF_8));
710+
}
655711
} catch (IOException e) {
656712
throw new UncheckedIOException("Failed to bootstrap openrewrite package", e);
657713
} catch (InterruptedException e) {

0 commit comments

Comments
 (0)