From 846d795232a8a2b9a31cfcbdf817076fb912fa83 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Wed, 25 Feb 2026 10:24:28 +0100 Subject: [PATCH 1/3] 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@`. 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==` 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 --- rewrite-python/.gitignore | 1 + rewrite-python/build.gradle.kts | 32 ++++++++++++++++++- .../python/rpc/PythonRewriteRpc.java | 31 +++++++++++++++--- 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 rewrite-python/.gitignore diff --git a/rewrite-python/.gitignore b/rewrite-python/.gitignore new file mode 100644 index 00000000000..61b82ac1134 --- /dev/null +++ b/rewrite-python/.gitignore @@ -0,0 +1 @@ +src/main/resources/META-INF/version.txt diff --git a/rewrite-python/build.gradle.kts b/rewrite-python/build.gradle.kts index 02883e2e1d4..ac1fe0482e6 100644 --- a/rewrite-python/build.gradle.kts +++ b/rewrite-python/build.gradle.kts @@ -1,5 +1,6 @@ @file:Suppress("UnstableApiUsage") +import nl.javadude.gradle.plugins.license.LicenseExtension import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -196,7 +197,7 @@ tasks.withType { // This is separate from Gradle because IntelliJ's Gradle integration doesn't support Python source roots. // ============================================ -// Python Publishing Tasks (PyPI) +// Version Resource (for RPC version pinning) // ============================================ // Generate a PEP 440 compliant version for CI builds @@ -211,6 +212,31 @@ val pythonVersion: String = if (System.getenv("CI") != null) { project.version.toString().replace("-SNAPSHOT", ".dev0") } +// Write version.txt resource so PythonRewriteRpc can pin the pip package version +val generateVersionTxt by tasks.registering { + group = "python" + description = "Generate META-INF/version.txt for RPC version pinning" + + val versionTxt = file("src/main/resources/META-INF/version.txt") + inputs.property("version", pythonVersion) + outputs.file(versionTxt) + + doLast { + versionTxt.parentFile.mkdirs() + versionTxt.writeText(pythonVersion) + } +} + +listOf("sourcesJar", "processResources", "licenseMain", "assemble").forEach { + tasks.named(it) { + dependsOn(generateVersionTxt) + } +} + +// ============================================ +// Python Publishing Tasks (PyPI) +// ============================================ + // Task to update version in pyproject.toml val pythonUpdateVersion by tasks.registering { group = "python" @@ -364,3 +390,7 @@ val printTestClasspath by tasks.registering { } } +extensions.configure { + exclude("**/version.txt") +} + diff --git a/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java b/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java index 8818525c6b6..5e959a59ec4 100644 --- a/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java +++ b/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java @@ -19,6 +19,7 @@ import lombok.RequiredArgsConstructor; import org.jspecify.annotations.Nullable; import org.openrewrite.*; +import org.openrewrite.internal.StringUtils; import org.openrewrite.marketplace.RecipeBundleResolver; import org.openrewrite.marketplace.RecipeMarketplace; import org.openrewrite.python.*; @@ -607,11 +608,28 @@ public PythonRewriteRpc get() { * This is required for the RPC server to start. */ private void bootstrapOpenrewrite(Path pipPackagesPath) { - Path rewriteModule = pipPackagesPath.resolve("rewrite"); - if (Files.exists(rewriteModule)) { - return; // Already installed + String version = StringUtils.readFully( + PythonRewriteRpc.class.getResourceAsStream("/META-INF/version.txt")); + boolean pinVersion = !version.isEmpty() && !version.endsWith(".dev0"); + + Path versionMarker = pipPackagesPath.resolve(".openrewrite-version"); + if (Files.exists(pipPackagesPath.resolve("rewrite"))) { + // Already installed — check if version matches + if (!pinVersion) { + return; + } + try { + if (Files.exists(versionMarker) && + version.equals(new String(Files.readAllBytes(versionMarker), StandardCharsets.UTF_8).trim())) { + return; // Correct version already installed + } + } catch (IOException ignored) { + // Can't read marker, reinstall to be safe + } } + String packageSpec = pinVersion ? "openrewrite==" + version : "openrewrite"; + try { Files.createDirectories(pipPackagesPath); @@ -619,7 +637,7 @@ private void bootstrapOpenrewrite(Path pipPackagesPath) { pythonPath.toString(), "-m", "pip", "install", "--target=" + pipPackagesPath.toAbsolutePath().normalize(), - "openrewrite" + packageSpec ); pb.redirectErrorStream(true); if (log != null) { @@ -652,6 +670,11 @@ private void bootstrapOpenrewrite(Path pipPackagesPath) { if (exitCode != 0) { throw new RuntimeException("Failed to bootstrap openrewrite package, pip install exited with code " + exitCode); } + + // Write version marker so we can detect stale installs + if (pinVersion) { + Files.write(versionMarker, version.getBytes(StandardCharsets.UTF_8)); + } } catch (IOException e) { throw new UncheckedIOException("Failed to bootstrap openrewrite package", e); } catch (InterruptedException e) { From 996a932e9cc5e656d50fa1de1c21024de5648466 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Wed, 25 Feb 2026 10:52:30 +0100 Subject: [PATCH 2/3] 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. --- .../main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java | 1 + 1 file changed, 1 insertion(+) diff --git a/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java b/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java index 5e959a59ec4..68aae78d9b6 100644 --- a/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java +++ b/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java @@ -636,6 +636,7 @@ private void bootstrapOpenrewrite(Path pipPackagesPath) { ProcessBuilder pb = new ProcessBuilder( pythonPath.toString(), "-m", "pip", "install", + "--upgrade", "--target=" + pipPackagesPath.toAbsolutePath().normalize(), packageSpec ); From d04519516469f549d843645cbbb423e08c823091 Mon Sep 17 00:00:00 2001 From: Knut Wannheden Date: Wed, 25 Feb 2026 11:44:06 +0100 Subject: [PATCH 3/3] 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. --- .../python/rpc/PythonRewriteRpc.java | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java b/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java index 68aae78d9b6..778763f90ea 100644 --- a/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java +++ b/rewrite-python/src/main/java/org/openrewrite/python/rpc/PythonRewriteRpc.java @@ -520,8 +520,18 @@ public Builder pythonVersion(String pythonVersion) { @Override public PythonRewriteRpc get() { - // Bootstrap openrewrite package if pip packages path is set - if (pipPackagesPath != null) { + // For dev builds (version ending in .dev0), check whether the interpreter + // already has the rewrite package (e.g., from a venv with an editable install). + // If so, skip bootstrap and PYTHONPATH prepend so the interpreter's own + // version takes precedence. For release/CI builds, always use pipPackagesPath + // to ensure the correct pinned version. + String version = StringUtils.readFully( + PythonRewriteRpc.class.getResourceAsStream("/META-INF/version.txt")); + boolean isDevBuild = version.isEmpty() || version.endsWith(".dev0"); + boolean interpreterHasRewrite = isDevBuild && pipPackagesPath != null && canImportRewrite(pythonPath); + boolean usePipPackagesPath = pipPackagesPath != null && !interpreterHasRewrite; + + if (usePipPackagesPath) { bootstrapOpenrewrite(pipPackagesPath); } @@ -551,8 +561,8 @@ public PythonRewriteRpc get() { // Set up PYTHONPATH for the rewrite package List pythonPathParts = new ArrayList<>(); - // Add pip packages path first if set (takes priority) - if (pipPackagesPath != null) { + // Add pip packages path if the interpreter doesn't already have rewrite + if (usePipPackagesPath) { pythonPathParts.add(pipPackagesPath.toAbsolutePath().normalize().toString()); } @@ -603,6 +613,28 @@ public PythonRewriteRpc get() { } } + /** + * Checks whether the given Python interpreter can already import the rewrite package + * without any PYTHONPATH modifications. This detects venvs or system installs that + * already have the openrewrite package available. + */ + private static boolean canImportRewrite(Path pythonPath) { + try { + Process probe = new ProcessBuilder( + pythonPath.toString(), "-c", "import rewrite" + ).redirectErrorStream(true).start(); + // Drain output to prevent blocking + try (InputStream is = probe.getInputStream()) { + //noinspection StatementWithEmptyBody + while (is.read() != -1) { + } + } + return probe.waitFor(10, TimeUnit.SECONDS) && probe.exitValue() == 0; + } catch (IOException | InterruptedException e) { + return false; + } + } + /** * Ensures the openrewrite Python package is installed in the pip packages directory. * This is required for the RPC server to start.