From 7e6ea0e5055d5a18d499a7c62b8a0fa9c8e57ea6 Mon Sep 17 00:00:00 2001 From: TimurMalanin Date: Tue, 3 Feb 2026 19:08:12 +0000 Subject: [PATCH] PY-87281 Support versioned Python binaries PY-87081 Prefer shortest Python binary name when resolving interpreters in virtual environments. PY-87281 Support versioned Python binaries (python3, python3.13, pypy3, etc.) Replace hardcoded Python binary name sets with regex patterns to match versioned executables like python3.13, python3.11.5, pypy3.10. Merge-request: IJ-MR-189909 Merged-by: Timur Malanin GitOrigin-RevId: f60b1c15b75e63cd96fd21c087b051c38ab095fe --- .../python/venvReader/VirtualEnvReader.kt | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt b/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt index dea4a4140ccb..48c42a05db2c 100644 --- a/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt +++ b/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt @@ -138,8 +138,8 @@ class VirtualEnvReader private constructor( */ @RequiresBackgroundThread fun findPythonInPythonRoot(pathOrDir: PythonHomePath): PythonBinary? { - val pythonNames = getPythonBinaryNames(pathOrDir.osFamily) - if (pathOrDir.isRegularFile() && pathOrDir.name.lowercase() in pythonNames) { + val pythonPattern = getPythonBinaryPattern(pathOrDir.osFamily) + if (pathOrDir.isRegularFile() && pythonPattern.matches(pathOrDir.name)) { return pathOrDir } @@ -188,17 +188,22 @@ class VirtualEnvReader private constructor( } /** - * Looks for python binary among directory entries + * Looks for python binary among directory entries. + * Prefers the shortest name (e.g., "python" over "python3.12") to ensure consistent results, + * since virtual environments create multiple symlinks (python, python3, python3.12) + * and Files.newDirectoryStream() order is undefined. */ @RequiresBackgroundThread private fun findInterpreter(dir: Path): PythonBinary? = try { Files.newDirectoryStream(dir).use { stream -> - val pythonNames = getPythonBinaryNames(forcedOs ?: dir.osFamily) - stream.firstOrNull { - it.name.lowercase() in pythonNames && - it.isRegularFile() - } + val pythonPattern = getPythonBinaryPattern(forcedOs ?: dir.osFamily) + + val candidates = stream.filter { + it.isRegularFile() && pythonPattern.matches(it.name) + }.toList() + + candidates.minByOrNull { it.name.length } } } @@ -235,16 +240,20 @@ class VirtualEnvReader private constructor( @Suppress("VENV_IS_OK") // The only place it should be used in prod const val DEFAULT_VIRTUALENV_DIRNAME: String = ".venv" - private val POSIX_BINS = setOf("pypy", "python") - private val WIN_BINS = setOf("pypy.exe", "python.exe") + private val POSIX_PYTHON_PATTERN = Regex("^(pypy|python)(\\d+(\\.\\d+)*)?$") + private val WIN_PYTHON_PATTERN = Regex("^(pypy|python)(\\d+(\\.\\d+)*)?\\.exe$", RegexOption.IGNORE_CASE) private fun getLocalEelIfApp(): EelApi? = if (ApplicationManager.getApplication() != null) localEel else null - private fun getPythonBinaryNames(osFamily: EelOsFamily): Set { - val pythonNames = when (osFamily) { - EelOsFamily.Posix -> POSIX_BINS - EelOsFamily.Windows -> WIN_BINS + /** + * Returns a regex pattern that matches Python binary names. + * Matches: python, python3, python3.X, python3.X.Y, python3.X.Y.Z, etc., pypy, pypy3, pypy3.X, pypy3.X.Y, etc. + * (and .exe versions on Windows). + */ + private fun getPythonBinaryPattern(osFamily: EelOsFamily): Regex { + return when (osFamily) { + EelOsFamily.Posix -> POSIX_PYTHON_PATTERN + EelOsFamily.Windows -> WIN_PYTHON_PATTERN } - return pythonNames } } }