diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt index 52c40c7d315e..182e79efd6e5 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsRowModel.kt @@ -14,6 +14,8 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.util.Version as PlatformVersion import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.platform.eel.provider.getEelDescriptor +import com.intellij.platform.eel.provider.toEelApi import com.intellij.python.pytools.PyTool import com.intellij.python.pytools.PyToolsState import com.intellij.python.pytools.Version @@ -121,16 +123,23 @@ internal sealed interface PathFieldValue { /** User-supplied [PyToolsState.ToolEntry.customPathToExecutable]. */ data class Custom(val path: Path) : PathFieldValue - /** Path derived from system PATH. */ + /** Path auto-detected on PATH or in a well-known per-user install directory. */ data class AutoDetected(val path: Path) : PathFieldValue /** Neither configured nor discoverable. */ data object NotFound : PathFieldValue } -internal fun detect(tool: PyTool, customPath: Path?): PathFieldValue { +/** + * Resolve the row's displayed path. A user-supplied [customPath] wins; a [knownPath] (the exact path an + * installer just reported) is trusted next; otherwise the tool is auto-detected via [findExecutableInPath], + * which searches PATH **and** the well-known per-user install dirs — a plain PATH lookup misses the per-user + * scripts dirs pip/uv install into, which are frequently not on PATH on Windows (PY-91493). + */ +internal suspend fun detect(project: Project, tool: PyTool, customPath: Path?, knownPath: Path? = null): PathFieldValue { if (customPath != null) return PathFieldValue.Custom(customPath) - val auto = tool.findExecutableInPath() + if (knownPath != null) return PathFieldValue.AutoDetected(knownPath) + val auto = tool.findExecutableInPath(project.getEelDescriptor().toEelApi()) return if (auto != null) PathFieldValue.AutoDetected(auto) else PathFieldValue.NotFound } @@ -182,16 +191,18 @@ internal fun iconKindFor( */ internal fun ToolRow.probeVersion( scope: CoroutineScope, + project: Project, isCustomEdit: Boolean = false, + knownPath: Path? = null, onUpdated: (ToolRow) -> Unit, ) { validationJob?.cancel() val mode = staged.mode val customPath = staged.customPath validationJob = scope.launch { - // Step 1: resolve the displayed path off the EDT — `findInPath` does disk I/O. + // Step 1: resolve the displayed path off the EDT — detection does disk I/O. val detected = withContext(Dispatchers.IO) { - detect(tool, customPath) + detect(project, tool, customPath, knownPath) } val path = when (detected) { is PathFieldValue.Custom -> detected.path diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt index b8bd5bfbf966..1b34e033b0be 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyExternalToolsTable.kt @@ -518,7 +518,7 @@ internal class PyExternalToolsTable( /** Start (or restart) the path-detection + `--version` probe for [item]. */ private fun probeRow(item: ToolRow, isCustomEdit: Boolean = false) { val scope = scope ?: return - item.probeVersion(scope, isCustomEdit, ::refreshRow) + item.probeVersion(scope, project, isCustomEdit, onUpdated = ::refreshRow) } /** Fire a single-row table update so the renderer picks up freshly-probed state. */ diff --git a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt index 0ed3575dadcd..465c8ffedcb9 100644 --- a/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt +++ b/python/python-pytools-ui/src/com/intellij/python/pytools/ui/configuration/PyToolManagementController.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.nio.file.Path /** * Backs the External Tools page's tool actions: the install / upgrade actions invoked from the Path @@ -211,7 +212,7 @@ internal class PyToolManagementController( toolRow: ToolRow, progressTitleKey: String, errorTitleKey: String, - action: suspend () -> PyResult<*>, + action: suspend () -> PyResult, onSuccess: () -> Unit = {}, /** * Fires once after the post-action `--version` re-probe publishes a version (or skips if @@ -244,12 +245,14 @@ internal class PyToolManagementController( refreshRow(toolRow) throw e } - val failure = result as? Result.Failure<*> - if (failure != null) { - toolRow.actionInProgress = false - refreshRow(toolRow) - Messages.showErrorDialog(project, failure.error.toString(), errorTitle) - return + val installedPath = when (result) { + is Result.Success -> result.result + is Result.Failure -> { + toolRow.actionInProgress = false + refreshRow(toolRow) + Messages.showErrorDialog(project, result.error.toString(), errorTitle) + return + } } onSuccess() // Invalidate the cached probe so the freshly installed/upgraded binary's version is re-fetched. @@ -264,7 +267,9 @@ internal class PyToolManagementController( } var probeCallbacks = 0 var versionResolvedFired = false - toolRow.probeVersion(activeScope) { updatedRow -> + // Seed the probe with the path the installer just reported, so the row reflects the freshly + // installed tool immediately even when its dir is not on PATH (PY-91493). + toolRow.probeVersion(activeScope, project, knownPath = installedPath) { updatedRow -> probeCallbacks++ if (!versionResolvedFired && updatedRow.version != null) { versionResolvedFired = true diff --git a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt index 2666b437419a..50d26551588d 100644 --- a/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt +++ b/python/python-pytools/src/com/intellij/python/pytools/PyToolExt.kt @@ -1,13 +1,12 @@ package com.intellij.python.pytools -import com.intellij.execution.configurations.PathEnvironmentVariableUtil import com.intellij.openapi.project.Project import com.intellij.platform.eel.EelApi import com.intellij.platform.eel.EelOsFamily -import com.intellij.platform.eel.provider.LocalEelDescriptor import com.intellij.platform.eel.provider.asNioPath import com.intellij.platform.eel.provider.getEelDescriptor import com.intellij.platform.eel.provider.localEel +import com.intellij.platform.eel.provider.toEelApi import com.intellij.platform.eel.provider.utils.stderrString import com.intellij.platform.eel.provider.utils.stdoutString import com.intellij.platform.eel.where @@ -25,6 +24,8 @@ import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.sdk.ModuleOrProject import com.jetbrains.python.sdk.PyRichSdk +import com.jetbrains.python.sdk.ToolCommandExecutor +import com.jetbrains.python.sdk.add.v2.toFileSystem import com.jetbrains.python.sdk.baseDir import com.jetbrains.python.sdk.moduleIfExists import com.jetbrains.python.sdk.pyRichSdk @@ -44,13 +45,14 @@ suspend fun PyTool.getExecutableWithBaseArgs( workingDir: Path? = null, ): PyResult>> { val state = getState(moduleOrProject.project) + val eelApi = moduleOrProject.project.getEelDescriptor().toEelApi() val toolBinaryPath = when (state.discoveryMode) { ExecutableDiscoveryMode.INTERPRETER -> { val pyRichSdk = moduleOrProject.moduleIfExists?.pythonSdk?.pyRichSdk() - pyRichSdk?.let { findExecutableInSdk(it, executableName) } ?: findExecutableInPath(state, executableName) + pyRichSdk?.let { findExecutableInSdk(it, executableName) } ?: findExecutableInPath(eelApi, state, executableName) } - ExecutableDiscoveryMode.PATH -> findExecutableInPath(state, executableName) + ExecutableDiscoveryMode.PATH -> findExecutableInPath(eelApi, state, executableName) ExecutableDiscoveryMode.UVX -> null } @@ -130,24 +132,22 @@ fun PyTool.findExecutableInSdk(pyRichSdk: PyRichSdk, executableName: String = pa } } -private fun PyTool.findExecutableInPath(state: PyToolsState.ToolEntry, executableName: String = packageName.name): Path? { - return state.customToolBinaryPath ?: findExecutableInPath(executableName) -} - -fun PyTool.findExecutableInPath( +private suspend fun PyTool.findExecutableInPath( + eelApi: EelApi, + state: PyToolsState.ToolEntry, executableName: String = packageName.name, - osFamily: EelOsFamily = LocalEelDescriptor.osFamily, -): Path? = resolveExecutableOnPath(executableName, osFamily) +): Path? = state.customToolBinaryPath ?: findExecutableInPath(eelApi, executableName) /** - * Looks up [executableName] on the system PATH by its OS-specific binary name. This is how the - * External Tools settings page resolves tool executables (via [findExecutableInPath]); shared so - * other callers can resolve an installed executable the same way. + * Resolve [executableName] in the environment [eelApi] describes: on `PATH` and in the well-known per-user + * install directories tool installers use (pip's user scripts dir, uv/pipx's `~/.local/bin`, …). Detection + * goes through [ToolCommandExecutor] so it matches how the tool was installed — a plain `PATH` lookup misses + * those per-user dirs, which are frequently not on `PATH` on Windows (PY-91493). */ -fun resolveExecutableOnPath( - executableName: String, - osFamily: EelOsFamily = LocalEelDescriptor.osFamily, -): Path? = PathEnvironmentVariableUtil.findInPath(osFamily.getOsSpecificBinaryName(executableName))?.toPath() +suspend fun PyTool.findExecutableInPath( + eelApi: EelApi, + executableName: String = packageName.name, +): Path? = ToolCommandExecutor(executableName).detectToolExecutable(eelApi.toFileSystem()) { true }?.path /** * Installs this tool's executable into the environment described by [eel], using the first available 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 455cc51e2aa4..4fba44b4cd64 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandExecutor.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/ToolCommandExecutor.kt @@ -28,7 +28,7 @@ import kotlin.time.Duration.Companion.minutes data class ToolCommandExecutor( private val toolName: @NlsSafe String, private val additionalSearchPaths: List = emptyList(), - private val getToolPathFromSettings: PropertiesComponent.() -> @SystemIndependent String?, + private val getToolPathFromSettings: PropertiesComponent.() -> @SystemIndependent String? = { null }, ) { companion object { private val KNOWN_SEARCH_PATHS = listOf( diff --git a/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt b/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt index 07e53be98851..221d6c50e6e8 100644 --- a/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt +++ b/python/src/com/jetbrains/python/sdk/configuration/SystemPythonToolManagerProvider.kt @@ -15,9 +15,9 @@ import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.getOrNull import com.jetbrains.python.packaging.PyPackageVersionNormalizer import com.jetbrains.python.packaging.repository.PyPiPackageRepository +import com.jetbrains.python.sdk.ToolCommandExecutor 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.installExecutableViaPythonScript import kotlinx.coroutines.Dispatchers @@ -61,7 +61,9 @@ private class SystemPythonToolManager( override suspend fun list(): Map { return PyTool.EP_NAME.extensionList.filter { it is ConfigurablePyTool }.mapNotNull { tool -> val name = tool.packageName.name - val executable = fileSystem.detectTool(name) ?: return@mapNotNull null + // Resolve on PATH and in the per-user scripts dirs the pip helper installs into (e.g. + // %APPDATA%\Python\Scripts on Windows), which are frequently not on PATH (PY-91493). + val executable = ToolCommandExecutor(name).detectToolExecutable(fileSystem) { true } ?: return@mapNotNull null val installed = BinOnEel(executable.path).getToolVersion(name).getOrNull()?.value ?: return@mapNotNull null val latest = latestPyPiVersion(name) ?: installed tool to InstalledInfo(path = executable.path, installedVersion = installed, latestVersion = latest)