Skip to content

Commit 33ac75a

Browse files
Python: Unify dev and release bootstrap for openrewrite package (#6839)
* Python: Unify dev and release bootstrap for openrewrite package The bootstrap logic now works the same for dev pre-releases (e.g., 8.75.0.dev0) and stable releases: 1. If pipPackagesPath is set, check for a version-specific install there 2. If the interpreter already has the right version, use it as-is 3. Otherwise install via pip to pipPackagesPath (or fail if not set) This eliminates the stale `dev/` directory that previously installed an unpinned PyPI release and never updated it. Dev pre-releases are now installed to version-specific subdirectories like stable releases. For local development without a version file (version is empty), the interpreter must already have the rewrite package available (e.g., via `uv sync --extra dev` in rewrite-python/rewrite/). * Python: Treat .dev0 versions as local dev builds in bootstrap The .dev0 suffix indicates a local dev build that isn't published to PyPI, so attempting `pip install openrewrite==X.Y.Z.dev0` will always fail. Restore the dev build check so these versions require the interpreter to already have the package (e.g. via editable install). Also improve pip install error messages to include the version being installed and capture pip output on failure. * Python: Treat unspecified version as local dev build, remove ad-hoc tests Handle Gradle's "unspecified" version (used in IDE runs) the same as .dev0 — require the interpreter to already have the rewrite package rather than attempting to pip-install a non-existent version. Also remove ad-hoc paramiko/fabric parsing tests that were committed by mistake. * Python: Treat all .dev versions as local dev builds, not just .dev0 CI builds produce timestamped versions like 8.75.0.dev20260227100441 which are also not published to PyPI. Use .contains(".dev") instead of .endsWith(".dev0") to catch all PEP 440 dev versions. * Python: Fall back to any rewrite install when exact version unavailable Timestamped .dev versions (e.g., 8.75.0.dev20260227100441) are real publishable pre-releases, not local dev builds. When the exact version isn't found and pipPackagesPath isn't set, fall back to checking if the interpreter can import rewrite at all. This handles CI test runs where the venv has an editable install that doesn't match the timestamped version.
1 parent f93dd01 commit 33ac75a

2 files changed

Lines changed: 93 additions & 260 deletions

File tree

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

Lines changed: 93 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -491,10 +491,11 @@ public Builder workingDirectory(@Nullable Path workingDirectory) {
491491

492492
/**
493493
* Set the base pip packages directory.
494-
* When set, a version-specific subdirectory (e.g., {@code <pipPackagesPath>/0.5.3/}
495-
* for releases, or {@code <pipPackagesPath>/dev/} for dev builds) will be resolved
496-
* and added to PYTHONPATH. The openrewrite package is automatically installed
497-
* into that subdirectory if not already present.
494+
* When set and the required release version is not already available in the
495+
* Python interpreter, a version-specific subdirectory (e.g.,
496+
* {@code <pipPackagesPath>/8.74.1/}) is resolved and the openrewrite package is
497+
* automatically installed there via pip. Dev builds ({@code .dev0}) are not
498+
* installed this way and require the interpreter to already have the package.
498499
*
499500
* @param pipPackagesPath The base directory under which version-specific pip packages are installed
500501
* @return This builder
@@ -536,23 +537,41 @@ public Builder pythonVersion(String pythonVersion) {
536537

537538
@Override
538539
public PythonRewriteRpc get() {
539-
// For dev builds (version ending in .dev0), check whether the interpreter
540-
// already has the rewrite package (e.g., from a venv with an editable install).
541-
// If so, skip bootstrap and PYTHONPATH prepend so the interpreter's own
542-
// version takes precedence. For release/CI builds, always use pipPackagesPath
543-
// to ensure the correct pinned version.
544540
String version = StringUtils.readFully(
545541
PythonRewriteRpc.class.getResourceAsStream("/META-INF/rewrite-python-version.txt")).trim();
546-
boolean isDevBuild = version.isEmpty() || version.endsWith(".dev0");
547-
boolean interpreterHasRewrite = isDevBuild && pipPackagesPath != null && canImportRewrite(pythonPath);
548-
boolean usePipPackagesPath = pipPackagesPath != null && !interpreterHasRewrite;
542+
boolean isDevBuild = version.isEmpty() || version.endsWith(".dev0") || "unspecified".equals(version);
549543

550-
// Resolve version-specific subdirectory under pipPackagesPath
551544
Path resolvedPipPackagesPath = null;
552-
if (usePipPackagesPath) {
553-
String versionDir = isDevBuild ? "dev" : version;
554-
resolvedPipPackagesPath = pipPackagesPath.resolve(versionDir);
555-
bootstrapOpenrewrite(resolvedPipPackagesPath, version, isDevBuild);
545+
if (!isDevBuild) {
546+
// Known version (release or published pre-release) — try to find or
547+
// install the pinned version, falling back to any available install.
548+
if (pipPackagesPath != null && isVersionInstalled(pipPackagesPath.resolve(version), version)) {
549+
resolvedPipPackagesPath = pipPackagesPath.resolve(version);
550+
} else if (hasRewriteVersion(pythonPath, version)) {
551+
// Interpreter already has the right version; nothing to do
552+
} else if (pipPackagesPath != null) {
553+
resolvedPipPackagesPath = pipPackagesPath.resolve(version);
554+
bootstrapOpenrewrite(resolvedPipPackagesPath, version);
555+
} else if (canImportRewrite(pythonPath)) {
556+
// Interpreter has a different version, but no pipPackagesPath to
557+
// install the right one — proceed with what's available (e.g., CI
558+
// running tests against a venv with an editable install)
559+
} else {
560+
throw new IllegalStateException(
561+
"The Python interpreter at " + pythonPath + " does not have openrewrite " + version + ". " +
562+
"Either set pipPackagesPath to allow automatic installation, " +
563+
"or install the package manually: pip install openrewrite==" + version);
564+
}
565+
} else {
566+
// Local dev build — require the interpreter to already have the rewrite
567+
// package (e.g., from a venv with an editable install).
568+
if (!canImportRewrite(pythonPath)) {
569+
throw new IllegalStateException(
570+
"The Python interpreter at " + pythonPath + " cannot import the 'rewrite' package. " +
571+
"For development builds, run 'uv sync --extra dev' in the rewrite-python/rewrite/ " +
572+
"directory to set up an editable install, then configure pythonPath to point to " +
573+
"the venv's Python executable.");
574+
}
556575
}
557576

558577
Stream<@Nullable String> cmd;
@@ -594,10 +613,9 @@ public PythonRewriteRpc get() {
594613
// If debug source path is set, use it
595614
if (debugRewriteSourcePath != null) {
596615
pythonPathParts.add(debugRewriteSourcePath.toAbsolutePath().normalize().toString());
597-
} else if (pipPackagesPath == null) {
598-
// Only search for development source if pip packages path is not set
599-
// Try to find the Python source in the project structure
600-
// Look for rewrite-python/rewrite/src relative to the working directory
616+
} else if (isDevBuild) {
617+
// For local dev builds, try to find the Python source in the project
618+
// structure (as a fallback for PYTHONPATH)
601619
Path basePath = workingDirectory != null ? workingDirectory : Paths.get(System.getProperty("user.dir"));
602620

603621
// Check common locations
@@ -639,16 +657,14 @@ public PythonRewriteRpc get() {
639657
}
640658

641659
/**
642-
* Checks whether the given Python interpreter can already import the rewrite package
643-
* without any PYTHONPATH modifications. This detects venvs or system installs that
644-
* already have the openrewrite package available.
660+
* Checks whether the given Python interpreter can import the rewrite package
661+
* (any version) without PYTHONPATH modifications.
645662
*/
646663
private static boolean canImportRewrite(Path pythonPath) {
647664
try {
648665
Process probe = new ProcessBuilder(
649666
pythonPath.toString(), "-c", "import rewrite"
650667
).redirectErrorStream(true).start();
651-
// Drain output to prevent blocking
652668
try (InputStream is = probe.getInputStream()) {
653669
//noinspection StatementWithEmptyBody
654670
while (is.read() != -1) {
@@ -661,30 +677,47 @@ private static boolean canImportRewrite(Path pythonPath) {
661677
}
662678

663679
/**
664-
* Ensures the openrewrite Python package is installed in the pip packages directory.
665-
* This is required for the RPC server to start.
680+
* Checks whether the given Python interpreter has a specific version of the
681+
* openrewrite package installed.
666682
*/
667-
private void bootstrapOpenrewrite(Path pipPackagesPath, String version, boolean isDevBuild) {
668-
boolean pinVersion = !isDevBuild;
669-
670-
Path versionMarker = pipPackagesPath.resolve(".openrewrite-version");
671-
if (Files.exists(pipPackagesPath.resolve("rewrite"))) {
672-
// Already installed — check if version matches
673-
if (!pinVersion) {
674-
return;
675-
}
676-
try {
677-
if (Files.exists(versionMarker) &&
678-
version.equals(new String(Files.readAllBytes(versionMarker), StandardCharsets.UTF_8).trim())) {
679-
return; // Correct version already installed
680-
}
681-
} catch (IOException ignored) {
682-
// Can't read marker, reinstall to be safe
683+
private static boolean hasRewriteVersion(Path pythonPath, String version) {
684+
try {
685+
Process probe = new ProcessBuilder(
686+
pythonPath.toString(), "-c",
687+
"from importlib.metadata import version; print(version('openrewrite'))"
688+
).redirectErrorStream(true).start();
689+
String output;
690+
try (InputStream is = probe.getInputStream()) {
691+
output = StringUtils.readFully(is).trim();
683692
}
693+
return probe.waitFor(10, TimeUnit.SECONDS) && probe.exitValue() == 0
694+
&& version.equals(output);
695+
} catch (IOException | InterruptedException e) {
696+
return false;
684697
}
698+
}
685699

686-
String packageSpec = pinVersion ? "openrewrite==" + version : "openrewrite";
700+
/**
701+
* Checks whether the version-specific pip packages directory already has the
702+
* correct version installed, based on a marker file.
703+
*/
704+
private static boolean isVersionInstalled(Path pipPackagesPath, String version) {
705+
if (!Files.exists(pipPackagesPath.resolve("rewrite"))) {
706+
return false;
707+
}
708+
Path versionMarker = pipPackagesPath.resolve(".openrewrite-version");
709+
try {
710+
return Files.exists(versionMarker) &&
711+
version.equals(new String(Files.readAllBytes(versionMarker), StandardCharsets.UTF_8).trim());
712+
} catch (IOException e) {
713+
return false;
714+
}
715+
}
687716

717+
/**
718+
* Installs the pinned openrewrite package into the given pip packages directory.
719+
*/
720+
private void bootstrapOpenrewrite(Path pipPackagesPath, String version) {
688721
try {
689722
Files.createDirectories(pipPackagesPath);
690723

@@ -693,44 +726,41 @@ private void bootstrapOpenrewrite(Path pipPackagesPath, String version, boolean
693726
"-m", "pip", "install",
694727
"--upgrade",
695728
"--target=" + pipPackagesPath.toAbsolutePath().normalize(),
696-
packageSpec
729+
"openrewrite==" + version
697730
);
698731
pb.redirectErrorStream(true);
699732
if (log != null) {
700733
File logFile = log.toAbsolutePath().normalize().toFile();
701734
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(logFile));
702735
}
703736
Process process = pb.start();
737+
String pipOutput = "";
704738
if (log == null) {
705-
// Drain stdout+stderr to prevent pipe buffer from filling and blocking
706-
Thread drainer = new Thread(() -> {
707-
try (InputStream is = process.getInputStream()) {
708-
byte[] buf = new byte[4096];
709-
//noinspection StatementWithEmptyBody
710-
while (is.read(buf) != -1) {
711-
}
712-
} catch (IOException ignored) {
713-
}
714-
});
715-
drainer.setDaemon(true);
716-
drainer.start();
739+
// Capture stdout+stderr so we can include it in error messages
740+
try (InputStream is = process.getInputStream()) {
741+
pipOutput = StringUtils.readFully(is);
742+
}
717743
}
718744
boolean completed = process.waitFor(2, TimeUnit.MINUTES);
719745

720746
if (!completed) {
721747
process.destroyForcibly();
722-
throw new RuntimeException("Timed out bootstrapping openrewrite package");
748+
throw new RuntimeException("Timed out bootstrapping openrewrite==" + version);
723749
}
724750

725751
int exitCode = process.exitValue();
726752
if (exitCode != 0) {
727-
throw new RuntimeException("Failed to bootstrap openrewrite package, pip install exited with code " + exitCode);
753+
String message = "Failed to install openrewrite==" + version +
754+
" (pip exited with code " + exitCode + ")";
755+
if (!pipOutput.isEmpty()) {
756+
message += ":\n" + pipOutput.trim();
757+
} else if (log != null) {
758+
message += ". See " + log.toAbsolutePath().normalize() + " for details";
759+
}
760+
throw new RuntimeException(message);
728761
}
729762

730-
// Write version marker so we can detect stale installs
731-
if (pinVersion) {
732-
Files.write(versionMarker, version.getBytes(StandardCharsets.UTF_8));
733-
}
763+
Files.write(pipPackagesPath.resolve(".openrewrite-version"), version.getBytes(StandardCharsets.UTF_8));
734764
} catch (IOException e) {
735765
throw new UncheckedIOException("Failed to bootstrap openrewrite package", e);
736766
} catch (InterruptedException e) {

0 commit comments

Comments
 (0)