diff --git a/python/helpers/tool_version_probe.sh b/python/helpers/tool_version_probe.sh index 2af5f2a36735..fd9d3f85cecc 100644 --- a/python/helpers/tool_version_probe.sh +++ b/python/helpers/tool_version_probe.sh @@ -148,19 +148,60 @@ print("true" if callable(is_gil_enabled) and not is_gil_enabled() else "false") printf '}' } +emit_environment_probe() { + possible_venv_home=$1 + [ -d "$possible_venv_home" ] || return 0 + + environment_python="${possible_venv_home%/}/bin/python" + if environment_probe=$(run_python_probe "$environment_python"); then + if [ "$first_environment" -eq 0 ]; then + printf ',' + fi + first_environment=0 + printf '{"path":' + json_string "$environment_python" + printf ',"python":%s}' "$environment_probe" + fi +} + +canonical_directory() { + (cd "$1" 2>/dev/null && pwd -P 2>/dev/null) || true +} + +emit_environment_probes_in_directory() { + directory_to_probe=$1 + [ -d "$directory_to_probe" ] || return 0 + + # Include regular and hidden direct children. Globbing is enabled only for these controlled suffixes. + set +f + for possible_venv_home in "$directory_to_probe"/*; do + emit_environment_probe "$possible_venv_home" + done + for possible_venv_home in "$directory_to_probe"/.*; do + case "${possible_venv_home##*/}" in + .|..) continue ;; + esac + emit_environment_probe "$possible_venv_home" + done + set -f +} + # argv protocol: # --python +# --detect-environments # repeated ... # A search path is one of: # absolute # env ... # home ... -# stdout is one JSON object containing shell, home, python, and found tools. Exit 2 means malformed argv. +# stdout is one JSON object containing shell, home, python, environments, and found tools. Exit 2 means malformed argv. # python is null when not requested, minimal isExecutable=false on failure, and a full object on success. -[ "$#" -ge 2 ] || exit 2 +[ "$#" -ge 4 ] || exit 2 [ "$1" = "--python" ] || exit 2 PYTHON_PATH=$2 -shift 2 +[ "$3" = "--detect-environments" ] || exit 2 +DETECT_ENVIRONMENTS_DIRECTORY=$4 +shift 4 USER_NAME=$(whoami 2>/dev/null || true) PASSWD_HOME= @@ -188,6 +229,21 @@ elif python_probe=$(run_python_probe "$PYTHON_PATH"); then else printf '{"isExecutable":false}' fi +printf ',"environments":[' + +first_environment=1 +if [ -n "$DETECT_ENVIRONMENTS_DIRECTORY" ]; then + REQUESTED_WORKING_DIRECTORY=$DETECT_ENVIRONMENTS_DIRECTORY + REQUESTED_WORKING_DIRECTORY_CANONICAL=$(canonical_directory "$REQUESTED_WORKING_DIRECTORY") + TARGET_WORKING_DIRECTORY=$(pwd -P 2>/dev/null || true) + if [ -n "$REQUESTED_WORKING_DIRECTORY_CANONICAL" ]; then + emit_environment_probes_in_directory "$REQUESTED_WORKING_DIRECTORY" + fi + if [ -n "$TARGET_WORKING_DIRECTORY" ] && [ "$TARGET_WORKING_DIRECTORY" != "$REQUESTED_WORKING_DIRECTORY_CANONICAL" ]; then + emit_environment_probes_in_directory "$TARGET_WORKING_DIRECTORY" + fi +fi +printf ']' printf ',"tools":{' first_tool=1 diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandExecutor.kt b/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandExecutor.kt index 7a99b252f03a..455cc51e2aa4 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandExecutor.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandExecutor.kt @@ -41,11 +41,7 @@ data class ToolCommandExecutor( suspend fun

detectToolExecutable( fileSystem: FileSystem

, filter: (P) -> Boolean, - ): P? { - val toolSpec = toCommandSpec() - val resolvedSearchPaths = fileSystem.resolveToolSearchPaths(toolSpec) - return fileSystem.detectTool(toolSpec.toolName, resolvedSearchPaths, filter) - } + ): P? = fileSystem.detectTool(toCommandSpec(), filter) fun toCommandSpec(): ToolCommandSpec = ToolCommandSpec(toolName, KNOWN_SEARCH_PATHS + additionalSearchPaths) diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandSpec.kt b/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandSpec.kt index d4802027b0d0..f5ba01d8f31a 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandSpec.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandSpec.kt @@ -4,7 +4,6 @@ package com.jetbrains.python.sdk import com.intellij.execution.Platform import com.intellij.execution.target.FullPathOnTarget import com.intellij.openapi.util.NlsSafe -import com.jetbrains.python.sdk.add.v2.FileSystem import com.jetbrains.python.sdk.add.v2.PathHolder import org.jetbrains.annotations.ApiStatus @@ -24,17 +23,6 @@ data class ToolProbeResult

( val versionOutput: String?, ) -@ApiStatus.Internal -suspend fun

FileSystem

.resolveToolSearchPaths(toolSpec: ToolCommandSpec): List

{ - return toolSpec.searchPathsFor(platformAndRoot.platform).mapNotNull { searchPath -> - when (searchPath) { - is ToolSearchPath.AbsolutePath -> parsePath(searchPath.path).successOrNull - is ToolSearchPath.RelativePath -> getFullPath(searchPath.prefixEnvVar, searchPath.pathComponents) - is ToolSearchPath.RelativePathFromHome -> getFullPathFromHome(searchPath.pathComponents) - } - } -} - /** * Represents a location to search for a tool executable. * diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt b/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt index bc52cd3adf85..6f82bb892a2d 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt @@ -84,11 +84,7 @@ interface FileSystem

{ fun normalizePathToRemote(path: P): P suspend fun detectEnvironments(workingDir: Path, uiInfoGetter: (P) -> PyToolUIInfo?): List> - suspend fun detectTool( - toolName: String, - additionalSearchPaths: List

= listOf(), - filter: (P) -> Boolean = { true }, - ): P? + suspend fun detectTool(toolSpec: ToolCommandSpec, filter: (P) -> Boolean = { true }): P? suspend fun probeTools(toolSpecs: List): PyResult>> diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystemExt.kt b/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystemExt.kt new file mode 100644 index 000000000000..d280c072212f --- /dev/null +++ b/python/python-sdk/src/com/jetbrains/python/sdk/add/v2/FileSystemExt.kt @@ -0,0 +1,8 @@ +package com.jetbrains.python.sdk.add.v2 + +import com.jetbrains.python.sdk.ToolCommandSpec +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Internal +suspend fun

FileSystem

.detectTool(toolName: String, filter: (P) -> Boolean = { true }): P? = + detectTool(ToolCommandSpec(toolName, emptyList()), filter) diff --git a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt index 4ebbc8894a09..2f8c8939ed00 100644 --- a/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt +++ b/python/python-uv/backend/src/com/intellij/python/uv/backend/UvToolManagerProvider.kt @@ -13,6 +13,7 @@ import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.getOrNull import com.jetbrains.python.sdk.add.v2.FileSystem import com.jetbrains.python.sdk.add.v2.PathHolder +import com.jetbrains.python.sdk.add.v2.detectTool import com.jetbrains.python.sdk.add.v2.toFileSystem import com.jetbrains.python.sdk.impl.PySdkBundle import org.jetbrains.annotations.ApiStatus diff --git a/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkFlavorData.kt b/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkFlavorData.kt index 81a6ca930637..e57f6ea222db 100644 --- a/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkFlavorData.kt +++ b/python/python-uv/backend/src/com/jetbrains/python/sdk/uv/UvSdkFlavorData.kt @@ -11,7 +11,7 @@ import org.jetbrains.annotations.ApiStatus import java.nio.file.Path /** - * TODO PY-87712 Should drop as a whole + * TODO PY-91452 Should drop as a whole * uvWorkingDirectory - workingDirectory in PythonSdkAdditionalData * usePip - can be deduced based on requirementsFile in PythonSdkAdditionalData * venvPath - sdkHome diff --git a/python/src/com/jetbrains/python/run/PythonScripts.kt b/python/src/com/jetbrains/python/run/PythonScripts.kt index 367499a4ff05..326f3ee4d6d9 100644 --- a/python/src/com/jetbrains/python/run/PythonScripts.kt +++ b/python/src/com/jetbrains/python/run/PythonScripts.kt @@ -82,7 +82,6 @@ fun PythonExecution.buildTargetedCommandLine( if (runTool != null) { applyRunToolAsync(commandLineBuilder, runTool) - // TODO PY-87712 maybe need proper handling of envs (duplicates?) runTool.envs.forEach { (k, v) -> commandLineBuilder.addEnvironmentVariable(k, v) } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt b/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt index 55e544d8cdb1..66d31fa584cd 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/FileSystem.kt @@ -68,6 +68,7 @@ import com.jetbrains.python.sdk.PythonSdkType import com.jetbrains.python.sdk.PythonSdkUtil import com.jetbrains.python.sdk.ToolCommandSpec import com.jetbrains.python.sdk.ToolProbeResult +import com.jetbrains.python.sdk.ToolSearchPath import com.jetbrains.python.sdk.asBinToExecute import com.jetbrains.python.sdk.associatedModulePath import com.jetbrains.python.sdk.createSdk @@ -76,7 +77,6 @@ import com.jetbrains.python.sdk.impl.PySdkBundle import com.jetbrains.python.sdk.impl.resolvePythonBinary import com.jetbrains.python.sdk.impl.resolvePythonHome import com.jetbrains.python.sdk.isSystemWide -import com.jetbrains.python.sdk.resolveToolSearchPaths import com.jetbrains.python.target.PyTargetAwareAdditionalData import com.jetbrains.python.target.PythonLanguageRuntimeConfiguration import com.jetbrains.python.target.ui.TargetPanelExtension @@ -315,10 +315,10 @@ data class EelFileSystem( } override suspend fun detectTool( - toolName: String, - additionalSearchPaths: List, + toolSpec: ToolCommandSpec, filter: (PathHolder.Eel) -> Boolean, ): PathHolder.Eel? = withContext(Dispatchers.IO) { + val toolName = toolSpec.toolName val fromPath = eelApi.exec.where(toolName) ?.asNioPath() ?.let { PathHolder.Eel(it) } @@ -328,6 +328,7 @@ data class EelFileSystem( val binaryNames = if (eelApi.platform.isWindows) listOf("$toolName.exe", "$toolName.bat") else listOf(toolName) + val additionalSearchPaths = resolveToolSearchPaths(toolSpec) for (path in additionalSearchPaths) { assert(path.path.getEelDescriptor() == eelApi.descriptor) { "Additional search paths should be on the same descriptor as EelFileSystem API, but $path isn't on $eelApi" @@ -349,8 +350,7 @@ data class EelFileSystem( ): PyResult>> = withContext(Dispatchers.IO) { val result = linkedMapOf>() for (toolSpec in toolSpecs) { - val resolvedSearchPaths = resolveToolSearchPaths(toolSpec) - val path = detectTool(toolSpec.toolName, resolvedSearchPaths) ?: continue + val path = detectTool(toolSpec) ?: continue val versionOutput = ExecService().execGetStdout(getBinaryToExec(path), Args("--version")).successOrNull result[toolSpec.toolName] = ToolProbeResult(path, versionOutput) } @@ -383,6 +383,7 @@ data class EelFileSystem( data class TargetFileSystem( val targetEnvironmentConfiguration: TargetEnvironmentConfiguration, private val pythonLanguageRuntimeConfiguration: PythonLanguageRuntimeConfiguration, + private val targetProbeWorkingDirectory: Path? = null, ) : FileSystem { override val isReadOnly: Boolean get() = !PythonInterpreterTargetEnvironmentFactory.isMutable(targetEnvironmentConfiguration) @@ -398,6 +399,7 @@ data class TargetFileSystem( private lateinit var home: PathHolder.Target private val toolProbeCache = ToolProbeCache>() private val targetUserInfoLock = Mutex() + private var detectedEnvironments: List? = null override fun parsePath(raw: String): PyResult { return PyResult.success(PathHolder.Target(raw)) @@ -415,7 +417,7 @@ data class TargetFileSystem( ) { val targetType = targetEnvironmentConfiguration.getTargetType() if (targetType is BrowsableTargetEnvironmentType) { - val descriptor = FileChooserDescriptorFactory.singleFile().withTitle(browseTitle) + val descriptor = FileChooserDescriptorFactory.singleFileOrDir().withTitle(browseTitle) val hints = TargetBrowserHints(showLocalFsInBrowser = true, descriptor) val actionListener = targetType.createBrowser( @@ -612,12 +614,21 @@ data class TargetFileSystem( return PathHolder.Target(targetPath) } - // TODO PY-87712 Support detection for remotes override suspend fun detectEnvironments( workingDir: Path, uiInfoGetter: (PathHolder.Target) -> PyToolUIInfo?, ): List> { - return emptyList() + probeTools(ADD_INTERPRETER_TOOL_COMMAND_SPECS).orLogException(LOG) ?: return emptyList() + val environments = targetUserInfoLock.withLock { detectedEnvironments.orEmpty() } + return environments.mapNotNull { environment -> + val pythonInfo = environment.python.toPythonInfo() ?: return@mapNotNull null + DetectedSelectableInterpreter( + homePath = environment.path, + pythonInfo = pythonInfo, + isBase = false, + ui = uiInfoGetter(environment.path), + ) + } } private suspend fun which(cmd: String): PathHolder.Target? { @@ -627,13 +638,18 @@ data class TargetFileSystem( } override suspend fun detectTool( - toolName: String, - additionalSearchPaths: List, + toolSpec: ToolCommandSpec, filter: (PathHolder.Target) -> Boolean, ): PathHolder.Target? = withContext(Dispatchers.IO) { + val toolName = toolSpec.toolName + if (toolSpec in ADD_INTERPRETER_TOOL_COMMAND_SPECS) { + val probeResult = probeTools(ADD_INTERPRETER_TOOL_COMMAND_SPECS).orLogException(LOG) + if (probeResult != null) return@withContext probeResult[toolName]?.path + } val fromWhich = which(toolName)?.takeIf(filter) if (fromWhich != null) return@withContext fromWhich + val additionalSearchPaths = resolveToolSearchPaths(toolSpec) for (path in additionalSearchPaths) { val candidate = parsePath("${path.pathString}/$toolName").successOrNull ?.takeIf { filter(it) && fileExists(it) } @@ -650,7 +666,7 @@ data class TargetFileSystem( val probes = toolProbeCache.getOrLoad(toolSpecs) loader@{ missingSpecs -> val pythonPath = getPythonPathToProbe() - val snapshot = probeTargetTools(missingSpecs, pythonPath).getOr { return@loader it } + val snapshot = probeTargetTools(missingSpecs, pythonPath, targetProbeWorkingDirectory).getOr { return@loader it } updateFromProbe(snapshot, pythonPath) PyResult.success(snapshot.tools) }.getOr { return@withContext it } @@ -693,19 +709,24 @@ data class TargetFileSystem( if (pythonPath != null && cachedPython != null) { systemPythonCache.putIfAbsent(pythonPath, cachedPython) } + if (detectedEnvironments == null) { + detectedEnvironments = snapshot.environments + } } } private fun TargetPythonProbe.toCachedSystemPython(path: PathHolder.Target): CachedSystemPython? { return when (this) { TargetPythonProbe.NotExecutable -> CachedSystemPython.NotExecutable - is TargetPythonProbe.Executable -> { - val languageLevel = getLanguageLevelFromVersionStringSafe(versionOutput.trim()) ?: return null - CachedSystemPython.Executable(createSystemPython(path, PythonInfo(languageLevel, freeThreaded))) - } + is TargetPythonProbe.Executable -> toPythonInfo()?.let { CachedSystemPython.Executable(createSystemPython(path, it)) } } } + private fun TargetPythonProbe.Executable.toPythonInfo(): PythonInfo? { + val languageLevel = getLanguageLevelFromVersionStringSafe(versionOutput.trim()) ?: return null + return PythonInfo(languageLevel, freeThreaded) + } + override suspend fun getFullPath(prefixEnvVar: String, pathComponents: List): PathHolder.Target? { val prefix = getEnvVar(prefixEnvVar).successOrNull ?: return null return getFullPathWithPrefix(prefix, pathComponents) @@ -823,3 +844,13 @@ internal suspend fun

FileSystem

.getExistingSelectableInterpr } allValidSdks } + +private suspend fun

FileSystem

.resolveToolSearchPaths(toolSpec: ToolCommandSpec): List

{ + return toolSpec.searchPathsFor(platformAndRoot.platform).mapNotNull { searchPath -> + when (searchPath) { + is ToolSearchPath.AbsolutePath -> parsePath(searchPath.path).successOrNull + is ToolSearchPath.RelativePath -> getFullPath(searchPath.prefixEnvVar, searchPath.pathComponents) + is ToolSearchPath.RelativePathFromHome -> getFullPathFromHome(searchPath.pathComponents) + } + } +} diff --git a/python/src/com/jetbrains/python/sdk/add/v2/TargetToolProbe.kt b/python/src/com/jetbrains/python/sdk/add/v2/TargetToolProbe.kt index b2edde2eeff7..e590bbf4bb64 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/TargetToolProbe.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/TargetToolProbe.kt @@ -16,10 +16,12 @@ import com.jetbrains.python.sdk.ToolSearchPath import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json +import java.nio.file.Path import kotlin.time.Duration.Companion.minutes private const val TOOL_VERSION_PROBE_HELPER = "tool_version_probe.sh" private const val PYTHON_PATH_OPTION = "--python" +private const val DETECT_ENVIRONMENTS_OPTION = "--detect-environments" private const val SEARCH_PATH_KIND_ABSOLUTE = "absolute" private const val SEARCH_PATH_KIND_ENV = "env" private const val SEARCH_PATH_KIND_HOME = "home" @@ -29,6 +31,7 @@ private val TOOL_PROBE_JSON = Json { ignoreUnknownKeys = true } internal suspend fun TargetFileSystem.probeTargetTools( toolSpecs: List, pythonPath: PathHolder.Target?, + workingDir: Path?, ): PyResult { if (platformAndRoot.platform == Platform.WINDOWS) { return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.windows.unsupported")) @@ -36,10 +39,9 @@ internal suspend fun TargetFileSystem.probeTargetTools( val helper = PythonHelpersLocator.findPathInHelpersPossibleNull(TOOL_VERSION_PROBE_HELPER) ?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.helper.missing", TOOL_VERSION_PROBE_HELPER)) - val args = Args().addLocalFile(helper).addArgs(encodeToolProbeArgs(toolSpecs, pythonPath)) val output = ExecService().execGetStdout( getBinaryToExec(PathHolder.Target("/bin/sh")), - args, + prepareArgs(helper, toolSpecs, pythonPath, workingDir), ExecOptions(timeout = 2.minutes), ).getOr { return it } val serializedSnapshot = try { @@ -53,20 +55,42 @@ internal suspend fun TargetFileSystem.probeTargetTools( serializedProbe.toTargetPythonProbe() ?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.output.invalid")) } + val environments = serializedSnapshot.environments.map { environment -> + val path = environment.path.takeIf { it.isNotBlank() } + ?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.output.invalid")) + val python = environment.python.toTargetPythonProbe() as? TargetPythonProbe.Executable + ?: return PyResult.localizedError(PyBundle.message("python.sdk.target.tool.probe.output.invalid")) + TargetEnvironmentProbe(PathHolder.Target(path), python) + } val tools = toolSpecs.mapNotNull { toolSpec -> val tool = serializedSnapshot.tools[toolSpec.toolName] ?: return@mapNotNull null val path = tool.path.takeIf { it.isNotBlank() } ?: return@mapNotNull null toolSpec to ToolProbeResult(PathHolder.Target(path), tool.versionOutput) }.toMap() - return PyResult.success(TargetProbeSnapshot(serializedSnapshot.home, serializedSnapshot.shell, pythonProbe, tools)) + return PyResult.success(TargetProbeSnapshot(serializedSnapshot.home, serializedSnapshot.shell, pythonProbe, environments, tools)) +} + +private fun TargetFileSystem.prepareArgs( + helper: Path, + toolSpecs: List, + pythonPath: PathHolder.Target?, + workingDir: Path?, +): Args { + val args = Args() + .addLocalFile(helper) + .addArgs(PYTHON_PATH_OPTION, pythonPath?.pathString.orEmpty()) + + if (workingDir != null) { + // Target file arguments are mapped through their parent. The "." keeps workingDir itself as the mapping root. + args.addArgs(DETECT_ENVIRONMENTS_OPTION).addLocalFile(workingDir.resolve(".")) + } + + return args.addArgs(encodeToolProbeArgs(toolSpecs)) } private fun TargetFileSystem.encodeToolProbeArgs( toolSpecs: List, - pythonPath: PathHolder.Target?, ): List = buildList { - add(PYTHON_PATH_OPTION) - add(pythonPath?.pathString.orEmpty()) for (toolSpec in toolSpecs) { val searchPaths = toolSpec.searchPathsFor(platformAndRoot.platform) add(toolSpec.toolName) @@ -98,9 +122,16 @@ private data class SerializedTargetProbeSnapshot( val shell: String, val home: String, val python: SerializedTargetPythonProbe? = null, + val environments: List = emptyList(), val tools: Map = emptyMap(), ) +@Serializable +private data class SerializedTargetEnvironmentProbe( + val path: FullPathOnTarget, + val python: SerializedTargetPythonProbe, +) + @Serializable private data class SerializedTargetPythonProbe( val isExecutable: Boolean, @@ -133,5 +164,11 @@ internal data class TargetProbeSnapshot( val home: String, val shell: String, val python: TargetPythonProbe?, + val environments: List, val tools: Map>, ) + +internal data class TargetEnvironmentProbe( + val path: PathHolder.Target, + val python: TargetPythonProbe.Executable, +) diff --git a/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt b/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt index 9543e5c0e21e..6b1f66093d8c 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/ValidatedPathField.kt @@ -289,10 +289,10 @@ internal class ValidatedPathField>( } } -private fun > Panel.installToolRow( +private fun > Panel.missingToolRow( fileSystem: FileSystem<*>, missingExecutableText: @Nls String, - installAction: ActionLink, + installAction: ActionLink?, validatedPathField: ValidatedPathField, ): Row { val selectExecutableLink = if (fileSystem.isBrowsable && fileSystem.toolPathCanBePersisted) ActionLink(message("sdk.create.custom.select.executable.link")) { @@ -302,7 +302,7 @@ private fun > Panel.installToolRow( return row("") { validationTooltip(missingExecutableText, - if (fileSystem.toolPathCanBePersisted) installAction else null, + installAction, selectExecutableLink, validationType = ValidationType.WARNING, inline = true) @@ -331,11 +331,11 @@ internal fun > Panel.validatablePath canBeEdited = canBeEdited, ) - if (missingExecutableText != null && installAction != null && !fileSystem.isReadOnly) { - installToolRow( + if (missingExecutableText != null) { + missingToolRow( fileSystem = fileSystem, missingExecutableText = missingExecutableText, - installAction = installAction, + installAction = if (canBeEdited) installAction else null, validatedPathField = validatedPathField ).visibleIf(pathValidator.backProperty.transform { it?.pathHolder == null }.and(pathValidator.isDirtyValue.not())) } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt b/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt index 40c6b3f94e9e..20d2bbaa32d8 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uv/EnvironmentCreatorUv.kt @@ -134,7 +134,6 @@ internal class EnvironmentCreatorUv

( installAction = createInstallFix(errorSink) ) - // TODO PY-87712 Add banner if the venv does exist at the specified location venvPathField = validatablePathField( fileSystem = model.fileSystem, pathValidator = model.uvViewModel.uvVenvValidator, diff --git a/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt b/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt index e48465eecc0f..34e489b37077 100644 --- a/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt +++ b/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt @@ -18,6 +18,7 @@ import com.jetbrains.python.packaging.PyPackageVersionNormalizer import com.jetbrains.python.packaging.repository.PyPiPackageRepository import com.jetbrains.python.sdk.add.v2.FileSystem import com.jetbrains.python.sdk.add.v2.PathHolder +import com.jetbrains.python.sdk.add.v2.detectTool import com.jetbrains.python.sdk.add.v2.toFileSystem import com.jetbrains.python.sdk.impl.PySdkBundle import com.jetbrains.python.sdk.installExecutableViaPythonScript diff --git a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt index 964b8ffbe49b..94ec0491fb36 100644 --- a/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt +++ b/python/src/com/jetbrains/python/sdk/uv/UvPackageManager.kt @@ -288,7 +288,6 @@ internal class UvPackageManager internal constructor( return PyProjectToml.parseCached(module.project, pyProjectFile.virtualFile)?.project?.name ?: module.name } - // TODO PY-87712 Double check for remotes override suspend fun resolveDependencyFilesTree(): List { val rootFile = getRootDependenciesFile() ?: return emptyList() val rootPyProjectToml = (rootFile as? PyProjectTomlFile) ?: return listOf(rootFile) diff --git a/python/src/com/jetbrains/python/sdk/uv/run/UvRunToolProvider.kt b/python/src/com/jetbrains/python/sdk/uv/run/UvRunToolProvider.kt index 792d046d8cd3..d76907df1c4d 100644 --- a/python/src/com/jetbrains/python/sdk/uv/run/UvRunToolProvider.kt +++ b/python/src/com/jetbrains/python/sdk/uv/run/UvRunToolProvider.kt @@ -30,7 +30,6 @@ internal class UvRunToolProvider : PySdkRunToolProvider() val uvExecutable = getUvExecutable(fileSystem, flavorData.uvPath)?.toString() - // TODO PY-87712 Duplicated code for setting up uv envs val pythonPath = fileSystem.parsePath(sdkHome).getOrThrow() val venvPath = fileSystem.resolvePythonHome(pythonPath).toString() env += "VIRTUAL_ENV" to venvPath diff --git a/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt b/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt index fedaa9949926..bee3abf4e97b 100644 --- a/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt +++ b/python/src/com/jetbrains/python/target/PythonLanguageRuntimeUI.kt @@ -56,11 +56,13 @@ internal class PythonLanguageRuntimeUI( override fun createPanel(): DialogPanel { val targetEnvironmentConfiguration = targetSupplier.get() + val projectPath = getProjectPath() val model = PythonLocalAddInterpreterModel( - ProjectPathFlows.create(getProjectPath()), + ProjectPathFlows.create(projectPath), TargetFileSystem( targetEnvironmentConfiguration = targetEnvironmentConfiguration, pythonLanguageRuntimeConfiguration = config, + targetProbeWorkingDirectory = projectPath, ) ) model.navigator.selectionMode = AtomicProperty(PythonInterpreterSelectionMode.CUSTOM) diff --git a/python/testSrc/com/jetbrains/python/sdk/add/v2/ToolVersionProbeTest.kt b/python/testSrc/com/jetbrains/python/sdk/add/v2/ToolVersionProbeTest.kt new file mode 100644 index 000000000000..7879ab56d6ed --- /dev/null +++ b/python/testSrc/com/jetbrains/python/sdk/add/v2/ToolVersionProbeTest.kt @@ -0,0 +1,113 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.sdk.add.v2 + +import com.google.gson.JsonParser +import com.intellij.python.community.execService.Args +import com.intellij.python.community.execService.BinOnEel +import com.intellij.python.community.execService.ExecService +import com.intellij.python.community.execService.execGetStdout +import com.intellij.python.community.helpersLocator.PythonHelpersLocator +import com.intellij.testFramework.common.timeoutRunBlocking +import com.intellij.testFramework.junit5.TestApplication +import com.jetbrains.python.allure.Layers +import com.jetbrains.python.allure.Subsystems +import com.jetbrains.python.getOrThrow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Timeout +import org.junit.jupiter.api.condition.DisabledOnOs +import org.junit.jupiter.api.condition.OS +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +@TestApplication +@Subsystems.Interpreters +@Layers.Functional +class ToolVersionProbeTest { + + @Test + @Timeout(30) + @DisabledOnOs(OS.WINDOWS) + fun `environment detection probes visible and hidden direct children`(@TempDir workingDirectory: Path): Unit = timeoutRunBlocking { + createFakePython(workingDirectory.resolve("venv"), "Python 3.12.7", freeThreaded = false) + createFakePython(workingDirectory.resolve(".venv"), "Python 3.13.1", freeThreaded = true) + createFakePython(workingDirectory.resolve("space env"), "Python 3.11.9", freeThreaded = false) + createBrokenPython(workingDirectory.resolve("broken")) + workingDirectory.resolve("not-an-environment").createDirectories() + + val helper = requireNotNull(PythonHelpersLocator.findPathInHelpersPossibleNull("tool_version_probe.sh")) + val output = ExecService().execGetStdout( + BinOnEel(Path.of("/bin/sh"), workingDirectory), + Args(helper.toString(), "--python", "", "--detect-environments", workingDirectory.toString()), + ).getOrThrow() + + val environmentRecords = requireNotNull(JsonParser.parseString(output).asJsonObject.getAsJsonArray("environments")) + val environments = environmentRecords.associateBy { it.asJsonObject.get("path").asString } + val expectedPaths = setOf( + workingDirectory.resolve("venv/bin/python").toString(), + workingDirectory.resolve(".venv/bin/python").toString(), + workingDirectory.resolve("space env/bin/python").toString(), + ) + + assertEquals(expectedPaths.size, environmentRecords.size()) + assertEquals(expectedPaths, environments.keys) + assertEquals("Python 3.12.7", environments.getValue(workingDirectory.resolve("venv/bin/python").toString()) + .asJsonObject.getAsJsonObject("python").get("versionOutput").asString) + assertEquals(true, environments.getValue(workingDirectory.resolve(".venv/bin/python").toString()) + .asJsonObject.getAsJsonObject("python").get("freeThreaded").asBoolean) + } + + @Test + @Timeout(30) + @DisabledOnOs(OS.WINDOWS) + fun `environment detection also probes a different process working directory`(@TempDir tempDirectory: Path): Unit = timeoutRunBlocking { + val requestedWorkingDirectory = tempDirectory.resolve("requested").createDirectories() + val processWorkingDirectory = tempDirectory.resolve("actual").createDirectories() + createFakePython(requestedWorkingDirectory.resolve("requested-venv"), "Python 3.12.7", freeThreaded = false) + createFakePython(processWorkingDirectory.resolve("actual-venv"), "Python 3.13.1", freeThreaded = true) + + val helper = requireNotNull(PythonHelpersLocator.findPathInHelpersPossibleNull("tool_version_probe.sh")) + val output = ExecService().execGetStdout( + BinOnEel(Path.of("/bin/sh"), processWorkingDirectory), + Args(helper.toString(), "--python", "", "--detect-environments", requestedWorkingDirectory.toString()), + ).getOrThrow() + + val environmentRecords = requireNotNull(JsonParser.parseString(output).asJsonObject.getAsJsonArray("environments")) + val actualPaths = environmentRecords.mapTo(mutableSetOf()) { it.asJsonObject.get("path").asString } + val expectedPaths = setOf( + requestedWorkingDirectory.resolve("requested-venv/bin/python").toString(), + processWorkingDirectory.toRealPath().resolve("actual-venv/bin/python").toString(), + ) + + assertEquals(expectedPaths, actualPaths) + } + + private fun createFakePython(environmentRoot: Path, version: String, freeThreaded: Boolean) { + val python = environmentRoot.resolve("bin/python") + python.parent.createDirectories() + python.writeText( + $$""" + |#!/bin/sh + |if [ "$1" = "-c" ]; then + | printf '%s\n' '$$freeThreaded' + |elif [ "$1" = "--version" ]; then + | printf '%s\n' '$$version' + |else + | exit 1 + |fi + """.trimMargin() + ) + Files.setPosixFilePermissions(python, PosixFilePermissions.fromString("rwx------")) + } + + private fun createBrokenPython(environmentRoot: Path) { + val python = environmentRoot.resolve("bin/python") + python.parent.createDirectories() + python.writeText("#!/bin/sh\nexit 1\n") + Files.setPosixFilePermissions(python, PosixFilePermissions.fromString("rwx------")) + } +}