@@ -491,10 +491,10 @@ 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 version is not already available in the Python
495+ * interpreter, a version-specific subdirectory (e.g., {@code <pipPackagesPath>/8.74.1/}
496+ * or {@code <pipPackagesPath>/8.75.0.dev0/}) is resolved and the openrewrite
497+ * package is automatically installed there via pip .
498498 *
499499 * @param pipPackagesPath The base directory under which version-specific pip packages are installed
500500 * @return This builder
@@ -536,23 +536,38 @@ public Builder pythonVersion(String pythonVersion) {
536536
537537 @ Override
538538 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.
544539 String version = StringUtils .readFully (
545540 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 ;
549541
550- // Resolve version-specific subdirectory under pipPackagesPath
551542 Path resolvedPipPackagesPath = null ;
552- if (usePipPackagesPath ) {
553- String versionDir = isDevBuild ? "dev" : version ;
554- resolvedPipPackagesPath = pipPackagesPath .resolve (versionDir );
555- bootstrapOpenrewrite (resolvedPipPackagesPath , version , isDevBuild );
543+ if (!version .isEmpty ()) {
544+ // Known version (release or dev pre-release) — try to find or install it.
545+ // 1. Check pipPackagesPath for an existing install
546+ // 2. Check if the interpreter already has the right version
547+ // 3. Install to pipPackagesPath if available, otherwise fail
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 {
556+ throw new IllegalStateException (
557+ "The Python interpreter at " + pythonPath + " does not have openrewrite " + version + ". " +
558+ "Either set pipPackagesPath to allow automatic installation, " +
559+ "or install the package manually: pip install openrewrite==" + version );
560+ }
561+ } else {
562+ // No version info (local dev build) — require the interpreter to already
563+ // have the rewrite package (e.g., from a venv with an editable install).
564+ if (!canImportRewrite (pythonPath )) {
565+ throw new IllegalStateException (
566+ "The Python interpreter at " + pythonPath + " cannot import the 'rewrite' package. " +
567+ "For development builds, run 'uv sync --extra dev' in the rewrite-python/rewrite/ " +
568+ "directory to set up an editable install, then configure pythonPath to point to " +
569+ "the venv's Python executable." );
570+ }
556571 }
557572
558573 Stream <@ Nullable String > cmd ;
@@ -594,10 +609,9 @@ public PythonRewriteRpc get() {
594609 // If debug source path is set, use it
595610 if (debugRewriteSourcePath != null ) {
596611 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
612+ } else if (version .isEmpty ()) {
613+ // For local dev builds without a version file, try to find the Python
614+ // source in the project structure (as a fallback for PYTHONPATH)
601615 Path basePath = workingDirectory != null ? workingDirectory : Paths .get (System .getProperty ("user.dir" ));
602616
603617 // Check common locations
@@ -639,16 +653,14 @@ public PythonRewriteRpc get() {
639653 }
640654
641655 /**
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.
656+ * Checks whether the given Python interpreter can import the rewrite package
657+ * (any version) without PYTHONPATH modifications.
645658 */
646659 private static boolean canImportRewrite (Path pythonPath ) {
647660 try {
648661 Process probe = new ProcessBuilder (
649662 pythonPath .toString (), "-c" , "import rewrite"
650663 ).redirectErrorStream (true ).start ();
651- // Drain output to prevent blocking
652664 try (InputStream is = probe .getInputStream ()) {
653665 //noinspection StatementWithEmptyBody
654666 while (is .read () != -1 ) {
@@ -661,30 +673,47 @@ private static boolean canImportRewrite(Path pythonPath) {
661673 }
662674
663675 /**
664- * Ensures the openrewrite Python package is installed in the pip packages directory.
665- * This is required for the RPC server to start .
676+ * Checks whether the given Python interpreter has a specific version of the
677+ * openrewrite package installed .
666678 */
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
679+ private static boolean hasRewriteVersion (Path pythonPath , String version ) {
680+ try {
681+ Process probe = new ProcessBuilder (
682+ pythonPath .toString (), "-c" ,
683+ "from importlib.metadata import version; print(version('openrewrite'))"
684+ ).redirectErrorStream (true ).start ();
685+ String output ;
686+ try (InputStream is = probe .getInputStream ()) {
687+ output = StringUtils .readFully (is ).trim ();
683688 }
689+ return probe .waitFor (10 , TimeUnit .SECONDS ) && probe .exitValue () == 0
690+ && version .equals (output );
691+ } catch (IOException | InterruptedException e ) {
692+ return false ;
684693 }
694+ }
685695
686- String packageSpec = pinVersion ? "openrewrite==" + version : "openrewrite" ;
696+ /**
697+ * Checks whether the version-specific pip packages directory already has the
698+ * correct version installed, based on a marker file.
699+ */
700+ private static boolean isVersionInstalled (Path pipPackagesPath , String version ) {
701+ if (!Files .exists (pipPackagesPath .resolve ("rewrite" ))) {
702+ return false ;
703+ }
704+ Path versionMarker = pipPackagesPath .resolve (".openrewrite-version" );
705+ try {
706+ return Files .exists (versionMarker ) &&
707+ version .equals (new String (Files .readAllBytes (versionMarker ), StandardCharsets .UTF_8 ).trim ());
708+ } catch (IOException e ) {
709+ return false ;
710+ }
711+ }
687712
713+ /**
714+ * Installs the pinned openrewrite package into the given pip packages directory.
715+ */
716+ private void bootstrapOpenrewrite (Path pipPackagesPath , String version ) {
688717 try {
689718 Files .createDirectories (pipPackagesPath );
690719
@@ -693,7 +722,7 @@ private void bootstrapOpenrewrite(Path pipPackagesPath, String version, boolean
693722 "-m" , "pip" , "install" ,
694723 "--upgrade" ,
695724 "--target=" + pipPackagesPath .toAbsolutePath ().normalize (),
696- packageSpec
725+ "openrewrite==" + version
697726 );
698727 pb .redirectErrorStream (true );
699728 if (log != null ) {
@@ -727,10 +756,7 @@ private void bootstrapOpenrewrite(Path pipPackagesPath, String version, boolean
727756 throw new RuntimeException ("Failed to bootstrap openrewrite package, pip install exited with code " + exitCode );
728757 }
729758
730- // Write version marker so we can detect stale installs
731- if (pinVersion ) {
732- Files .write (versionMarker , version .getBytes (StandardCharsets .UTF_8 ));
733- }
759+ Files .write (pipPackagesPath .resolve (".openrewrite-version" ), version .getBytes (StandardCharsets .UTF_8 ));
734760 } catch (IOException e ) {
735761 throw new UncheckedIOException ("Failed to bootstrap openrewrite package" , e );
736762 } catch (InterruptedException e ) {
0 commit comments