PY-91493 Resolve tools in per-user install dirs, not just on PATH

PyTool.findExecutableInPath now takes the environment's eel and detects via
ToolCommandExecutor (PATH + the known per-user install dirs) instead of a local
PATH-only lookup, so both tool execution and the External Tools page find tools
installed where they land on Windows (e.g. %APPDATA%\Python\Scripts, uv's bin).
The pip list() uses the same detection, and the External Tools row is seeded with
the exact path the installer reported.

GitOrigin-RevId: c39129f31b6b40ea8fcfcf4a94b74a275ff0241c
This commit is contained in:
Vitaly Legchilkin
2026-08-11 21:52:40 +00:00
committed by intellij-monorepo-bot
parent 0137d9f471
commit d79e980ebc
6 changed files with 53 additions and 35 deletions
@@ -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
@@ -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. */
@@ -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<Path>,
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
@@ -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<Pair<BinaryToExec, List<String>>> {
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
@@ -28,7 +28,7 @@ import kotlin.time.Duration.Companion.minutes
data class ToolCommandExecutor(
private val toolName: @NlsSafe String,
private val additionalSearchPaths: List<ToolSearchPath> = emptyList(),
private val getToolPathFromSettings: PropertiesComponent.() -> @SystemIndependent String?,
private val getToolPathFromSettings: PropertiesComponent.() -> @SystemIndependent String? = { null },
) {
companion object {
private val KNOWN_SEARCH_PATHS = listOf(
@@ -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<PyTool, InstalledInfo> {
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)