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..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 @@ -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.*; @@ -519,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); } @@ -550,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()); } @@ -602,24 +613,64 @@ 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. */ 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); ProcessBuilder pb = new ProcessBuilder( pythonPath.toString(), "-m", "pip", "install", + "--upgrade", "--target=" + pipPackagesPath.toAbsolutePath().normalize(), - "openrewrite" + packageSpec ); pb.redirectErrorStream(true); if (log != null) { @@ -652,6 +703,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) {