diff --git a/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt b/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt index 37baab199da5..01ad3d5a5c50 100644 --- a/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt +++ b/python/openapi/src/com/jetbrains/python/venvReader/VirtualEnvReader.kt @@ -1,42 +1,54 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.venvReader -import com.intellij.openapi.util.SystemInfoRt +import com.intellij.openapi.progress.runBlockingMaybeCancellable import com.intellij.openapi.util.io.toCanonicalPath -import com.intellij.util.SystemProperties +import com.intellij.platform.eel.EelApi +import com.intellij.platform.eel.EelOsFamily +import com.intellij.platform.eel.environmentVariables +import com.intellij.platform.eel.provider.asNioPath +import com.intellij.platform.eel.provider.getEelDescriptor +import com.intellij.platform.eel.provider.localEel import com.intellij.util.concurrency.annotations.RequiresBackgroundThread import com.jetbrains.python.PythonBinary import com.jetbrains.python.PythonHomePath +import com.jetbrains.python.venvReader.VirtualEnvReader.Companion.Instance import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.NonNls +import org.jetbrains.annotations.TestOnly import java.io.IOException import java.nio.file.Path import kotlin.io.path.* typealias Directory = Path +/** + * Use [Instance]. Provide "forced" vars to ctor for tests only. + */ @ApiStatus.Internal -class VirtualEnvReader( - private val envs: Map<@NonNls String, @NonNls String> = System.getenv(), - isWindows: Boolean = SystemInfoRt.isWindows, +class VirtualEnvReader private constructor( + private val forcedVars: Map<@NonNls String, @NonNls String>?, + private val forcedOs: EelOsFamily? = null, ) { - private val pythonNames = if (isWindows) - setOf("pypy.exe", "python.exe") - else - setOf("pypy", "python") - private val binFolderName = if (isWindows) { - "Scripts" - } else { - "bin" - } + @TestOnly + constructor( + forcedVars: Map<@NonNls String, @NonNls String>? = null, + isWindows: Boolean? = null, + ) : this(forcedVars, when (isWindows) { + true -> EelOsFamily.Windows + false -> EelOsFamily.Posix + null -> null + }) + + private constructor() : this(forcedVars = null, forcedOs = null) /** * Dir with virtual envs */ @RequiresBackgroundThread - fun getVEnvRootDir(): Directory { - return resolveDirFromEnvOrElseGetDirInHomePath("WORKON_HOME", DEFAULT_VIRTUALENVS_DIR) + fun getVEnvRootDir(eel: EelApi = localEel): Directory { + return resolveDirFromEnvOrElseGetDirInHomePath(eel, "WORKON_HOME", DEFAULT_VIRTUALENVS_DIR) } /** @@ -47,8 +59,8 @@ class VirtualEnvReader( findLocalInterpreters(getVEnvRootDir()) @RequiresBackgroundThread - fun getPyenvRootDir(): Directory { - return resolveDirFromEnvOrElseGetDirInHomePath("PYENV_ROOT", ".pyenv") + fun getPyenvRootDir(eel: EelApi = localEel): Directory { + return resolveDirFromEnvOrElseGetDirInHomePath(eel, "PYENV_ROOT", ".pyenv") } @RequiresBackgroundThread @@ -127,6 +139,11 @@ class VirtualEnvReader( fun getVenvRootPath(path: Path): Path? { val bin = path.parent + val binFolderName = when (forcedOs ?: path.getEelDescriptor().osFamily) { + EelOsFamily.Posix -> "bin" + EelOsFamily.Windows -> "Scripts" + } + if (bin == null || bin.fileName.pathString != binFolderName) { return null } @@ -150,13 +167,20 @@ class VirtualEnvReader( * Looks for python binary among directory entries */ @RequiresBackgroundThread - private fun findInterpreter(dir: Path): PythonBinary? = - dir.listDirectoryEntries().firstOrNull { it.isRegularFile() && it.name.lowercase() in pythonNames } + private fun findInterpreter(dir: Path): PythonBinary? { + val pythonNames = when (forcedOs ?: dir.getEelDescriptor().osFamily) { + EelOsFamily.Posix -> setOf("pypy", "python") + EelOsFamily.Windows -> setOf("pypy.exe", "python.exe") + } + return dir.listDirectoryEntries().firstOrNull { it.isRegularFile() && it.name.lowercase() in pythonNames } + } @RequiresBackgroundThread - private fun resolveDirFromEnvOrElseGetDirInHomePath(env: String, dirName: String): Path = - envs[env]?.let { tryResolvePath(it) } - ?: Path.of(SystemProperties.getUserHome(), dirName) + private fun resolveDirFromEnvOrElseGetDirInHomePath(eel: EelApi, env: String, dirName: String): Path { + val envs = forcedVars ?: runBlockingMaybeCancellable { eel.exec.environmentVariables().eelIt().await() } + return envs[env]?.let { tryResolvePath(it, eel.descriptor) } + ?: eel.userInfo.home.asNioPath().resolve(dirName) + } companion object { diff --git a/python/openapi/src/com/jetbrains/python/venvReader/resolveUtil.kt b/python/openapi/src/com/jetbrains/python/venvReader/resolveUtil.kt index 2d5a3198d48d..da144bb6bc8f 100644 --- a/python/openapi/src/com/jetbrains/python/venvReader/resolveUtil.kt +++ b/python/openapi/src/com/jetbrains/python/venvReader/resolveUtil.kt @@ -1,24 +1,33 @@ // Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.venvReader +import com.intellij.platform.eel.EelDescriptor +import com.intellij.platform.eel.path.EelPathException +import com.intellij.platform.eel.provider.localEel +import com.intellij.platform.eel.provider.utils.Path import com.jetbrains.python.sdk.CustomSdkHomePattern import org.jetbrains.annotations.ApiStatus import java.nio.file.InvalidPathException import java.nio.file.Path +import kotlin.io.path.Path /** * Converts [str] to [Path] of str is real nio path. Returns null otherwise */ +@JvmOverloads @ApiStatus.Internal -fun tryResolvePath(str: String?): Path? { - if (str == null || CustomSdkHomePattern.isCustomPythonSdkHomePath(str)) { +fun tryResolvePath(str: String?, eelDescriptor: EelDescriptor? = null): Path? { + val eelIsLocal = eelDescriptor == null || eelDescriptor == localEel.descriptor + if (str == null || eelIsLocal && CustomSdkHomePattern.isCustomPythonSdkHomePath(str)) { return null } try { - return Path.of(str) + return if (eelIsLocal) Path(str) else Path(str, eelDescriptor) } catch (_: InvalidPathException) { } + catch (_: EelPathException) { + } return null } diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/impl/PythonBinaryExt.kt b/python/python-sdk/src/com/jetbrains/python/sdk/impl/PythonBinaryExt.kt index 2ef28262b6e7..d33974dd0976 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/impl/PythonBinaryExt.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/impl/PythonBinaryExt.kt @@ -21,5 +21,5 @@ fun PythonBinary.resolvePythonHome(): PythonHomePath = when (getEelDescriptor(). @RequiresBackgroundThread @ApiStatus.Internal fun PythonHomePath.resolvePythonBinary(): PythonBinary? { - return VirtualEnvReader(isWindows = getEelDescriptor().osFamily.isWindows).findPythonInPythonRoot(this) + return VirtualEnvReader.Instance.findPythonInPythonRoot(this) } diff --git a/python/python-venv/BUILD.bazel b/python/python-venv/BUILD.bazel index c5e4b2c6d598..0e698000cd09 100644 --- a/python/python-venv/BUILD.bazel +++ b/python/python-venv/BUILD.bazel @@ -30,6 +30,7 @@ jvm_library( "//platform/util", "//platform/core-api:core", "//python/python-exec-service/execService.python", + "//platform/eel-provider", ] ) @@ -61,6 +62,7 @@ jvm_library( "//platform/core-api:core", "//python/python-exec-service/execService.python", "//python/python-exec-service/execService.python:execService.python_test_lib", + "//platform/eel-provider", ] ) ### auto-generated section `build intellij.python.community.impl.venv` end diff --git a/python/python-venv/intellij.python.community.impl.venv.iml b/python/python-venv/intellij.python.community.impl.venv.iml index 1f0719e3d3f5..b4d423e7815b 100644 --- a/python/python-venv/intellij.python.community.impl.venv.iml +++ b/python/python-venv/intellij.python.community.impl.venv.iml @@ -26,5 +26,6 @@ + \ No newline at end of file diff --git a/python/python-venv/src/com/intellij/python/community/impl/venv/venv.kt b/python/python-venv/src/com/intellij/python/community/impl/venv/venv.kt index 4af59d829730..77d357df24b1 100644 --- a/python/python-venv/src/com/intellij/python/community/impl/venv/venv.kt +++ b/python/python-venv/src/com/intellij/python/community/impl/venv/venv.kt @@ -3,6 +3,7 @@ package com.intellij.python.community.impl.venv import com.intellij.openapi.application.EDT import com.intellij.openapi.diagnostic.fileLogger +import com.intellij.platform.eel.provider.asEelPath import com.intellij.python.community.execService.BinaryToExec import com.intellij.python.community.execService.ExecOptions import com.intellij.python.community.execService.ExecService @@ -40,7 +41,7 @@ suspend fun createVenv( inheritSitePackages: Boolean = false, envReader: VirtualEnvReader = VirtualEnvReader.Instance, ): PyResult { - createVenv(python.asBinToExec(), venvDir.pathString, inheritSitePackages).getOr { return it } + createVenv(python.asBinToExec(), venvDir.asEelPath().toString(), inheritSitePackages).getOr { return it } val venvPython = withContext(Dispatchers.IO) { envReader.findPythonInPythonRoot(venvDir) diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt index 9b68e71210b9..131cc3b7f2ca 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/UvExistingEnvironmentSelector.kt @@ -56,7 +56,7 @@ internal class UvExistingEnvironmentSelector

(model: PythonMutabl } val workingDirectory = - VirtualEnvReader().getVenvRootPath(selectedInterpreterPath.path) + VirtualEnvReader.Instance.getVenvRootPath(selectedInterpreterPath.path) ?: tryResolvePath(existingSdk?.associatedModulePath) ?: projectDir diff --git a/python/testSrc/com/jetbrains/python/sdk/VirtualEnvReaderTest.kt b/python/testSrc/com/jetbrains/python/sdk/VirtualEnvReaderTest.kt index 9f3984976b26..4a47e40264df 100644 --- a/python/testSrc/com/jetbrains/python/sdk/VirtualEnvReaderTest.kt +++ b/python/testSrc/com/jetbrains/python/sdk/VirtualEnvReaderTest.kt @@ -2,12 +2,14 @@ package com.jetbrains.python.sdk import com.intellij.grazie.grammar.assertIsEmpty +import com.intellij.openapi.util.SystemInfoRt import com.intellij.openapi.util.io.FileUtilRt +import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.utils.io.deleteRecursively import com.jetbrains.python.venvReader.VirtualEnvReader import com.jetbrains.python.venvReader.tryResolvePath -import org.junit.Assert -import org.junit.Test +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -16,6 +18,7 @@ import java.nio.file.Path import kotlin.io.path.absolutePathString import kotlin.io.path.exists +@TestApplication class VirtualEnvReaderTest { inner class Bootstrap { val PYENV_ROOT = "PYENV_ROOT" @@ -24,7 +27,7 @@ class VirtualEnvReaderTest { val pyenv = cwd.resolve(".pyenv") val env = HashMap() - val virtualEnvReader = VirtualEnvReader(env, isWindows = false) + val virtualEnvReader = VirtualEnvReader(env) fun setupPyenv(versions: List, binary: String) { for (version in versions) { @@ -99,62 +102,69 @@ class VirtualEnvReaderTest { val bootstrap = Bootstrap() // just version - bootstrap.setupPyenv(listOf("3.1.1"), "python") + val binary = if (SystemInfoRt.isWindows) "python.exe" else "python" + bootstrap.setupPyenv(listOf("3.1.1"), binary) var interpreters = bootstrap.virtualEnvReader.findPyenvInterpreters() - Assert.assertEquals(1, interpreters.size) + Assertions.assertEquals(1, interpreters.size) assert(interpreters[0].absolutePathString().startsWith(bootstrap.pyenv.absolutePathString())) - assert(interpreters[0].absolutePathString().endsWith("python")) + assert(interpreters[0].absolutePathString().endsWith(binary)) // another version w/o match bootstrap.setupPyenv(listOf("3.2.1"), "xxx") interpreters = bootstrap.virtualEnvReader.findPyenvInterpreters() - Assert.assertEquals(1, interpreters.size) + Assertions.assertEquals(1, interpreters.size) // both in names - bootstrap.setupPyenv(listOf("3.2.2"), "pypy") + val pypyBinary = if (SystemInfoRt.isWindows) "pypy.exe" else "pypy" + bootstrap.setupPyenv(listOf("3.2.2"), pypyBinary) interpreters = bootstrap.virtualEnvReader.findPyenvInterpreters() - Assert.assertEquals(2, interpreters.size) + Assertions.assertEquals(2, interpreters.size) assert(interpreters[0] != interpreters[1]) bootstrap.removeVersion(bootstrap.pyenv, "3.2.2") interpreters = bootstrap.virtualEnvReader.findPyenvInterpreters() - Assert.assertEquals(1, interpreters.size) - assert(interpreters[0].absolutePathString().endsWith("python")) + Assertions.assertEquals(1, interpreters.size) + assert(interpreters[0].absolutePathString().endsWith(binary)) } @Test fun testIsPyenvSdk() { val bootstrap = Bootstrap() - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(null as String?)) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("")) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("aa\u0000bb")) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("a/b/c/d")) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd)) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd.resolve("smthg"))) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(null as String?)) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("")) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("aa\u0000bb")) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("a/b/c/d")) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd)) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd.resolve("smthg"))) bootstrap.setupPyenv(listOf("3.2.1"), "xxxx") - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(null as String?)) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("")) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("aa\u0000bb")) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("a/b/c/d")) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd)) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd.resolve("smthg"))) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(null as String?)) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("")) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("aa\u0000bb")) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk("a/b/c/d")) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd)) + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.cwd.resolve("smthg"))) // particularly any path inside pyenv root will work - Assert.assertTrue(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.pyenv.resolve("xxx"))) + Assertions.assertTrue(bootstrap.virtualEnvReader.isPyenvSdk(bootstrap.pyenv.resolve("xxx"))) // should resolve symlinks val link = bootstrap.cwd.resolve("smthg") val target = bootstrap.pyenv.resolve("xxx") // hanging links, should not resolve it - Files.createSymbolicLink(link, target) - Assert.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(link)) + if (!SystemInfoRt.isWindows) { + // links require UAC on Windows + Files.createSymbolicLink(link, target) + } + Assertions.assertFalse(bootstrap.virtualEnvReader.isPyenvSdk(link)) - Files.createFile(target) - Assert.assertTrue(bootstrap.virtualEnvReader.isPyenvSdk(link)) + if (!SystemInfoRt.isWindows) { + Files.createFile(target) + Assertions.assertTrue(bootstrap.virtualEnvReader.isPyenvSdk(link)) + } } @ParameterizedTest(name = "{0}") @@ -162,7 +172,7 @@ class VirtualEnvReaderTest { fun getVenvRootPathTests(name: String, isWindows: Boolean, path: Path, expectedReturnValue: Path?) { val result = VirtualEnvReader(isWindows = isWindows).getVenvRootPath(path) - Assert.assertEquals(expectedReturnValue, result) + Assertions.assertEquals(expectedReturnValue, result) } companion object { @@ -231,7 +241,7 @@ class VirtualEnvReaderTest { val name: String, val isWindows: Boolean, val path: String, - val expectedReturnValue: String? + val expectedReturnValue: String?, ) } } \ No newline at end of file