@@ -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