PY-84953: Unify tool detection approach

Our tool detection approach varies a lot. We have different logic for
uv, poetry and other tools. Also, uv detection is not suspendable and
doesn't have any explicit thread requirements, even though it performs
I/O operations.

This change makes such detection unified and suspendable (where
possible) and moves it to BGT.

GitOrigin-RevId: 18e9c4cc085c8d373c82ad2874033b53711f09c6
This commit is contained in:
Alexey Katsman
2025-10-30 17:07:32 +01:00
committed by intellij-monorepo-bot
parent cc191b2b3d
commit 27838bc2da
32 changed files with 217 additions and 168 deletions

View File

@@ -31,6 +31,7 @@ import com.jetbrains.python.sdk.PythonSdkType
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.configuration.*
import com.jetbrains.python.sdk.findAmongRoots
import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.impl.resolvePythonBinary
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import com.jetbrains.python.sdk.pipenv.*
@@ -139,12 +140,12 @@ class PyPipfileSdkConfiguration : PyProjectSdkConfigurationExtension {
val path = withContext(Dispatchers.IO) { VirtualEnvReader.Instance.findPythonInPythonRoot(Path.of(pipEnv)) }
if (path == null) {
return@withBackgroundProgress PyResult.localizedError(PyBundle.message("cannot.find.executable", "python", pipEnv))
return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", pipEnv))
}
val file = LocalFileSystem.getInstance().refreshAndFindFileByPath(path.toString())
if (file == null) {
return@withBackgroundProgress PyResult.localizedError(PyBundle.message("cannot.find.executable", "python", path))
return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", path))
}
PySdkConfigurationCollector.logPipEnv(module.project, PipEnvResult.CREATED)

View File

@@ -24,6 +24,7 @@ import com.jetbrains.python.projectModel.poetry.POETRY_TOOL_ID
import com.jetbrains.python.sdk.PythonSdkType
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.configuration.*
import com.jetbrains.python.sdk.impl.PySdkBundle
import com.jetbrains.python.sdk.impl.resolvePythonBinary
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import com.jetbrains.python.sdk.poetry.*
@@ -97,10 +98,10 @@ class PyPoetrySdkConfiguration : PyProjectTomlConfigurationExtension {
val tomlFile = PyProjectToml.findFile(module)
val poetry = setupPoetry(basePath, null, true, tomlFile == null).getOr { return@withBackgroundProgress it }
val path = poetry.resolvePythonBinary()
?: return@withBackgroundProgress PyResult.localizedError(PyBundle.message("cannot.find.executable", "python", poetry))
?: return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", poetry))
val file = LocalFileSystem.getInstance().refreshAndFindFileByPath(path.pathString)
?: return@withBackgroundProgress PyResult.localizedError(PyBundle.message("cannot.find.executable", "python", path))
?: return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", path))
LOGGER.debug("Setting up associated poetry environment: $path, $basePath")
val sdk = SdkConfigurationUtil.setupSdk(

View File

@@ -1742,7 +1742,6 @@ command.name.add.package.to.conda.environments.yml=Add a package to conda enviro
command.name.add.package.to.requirements.txt=Add a package to requirements.txt
command.name.add.package.to.setup.py=Add a package to setup.py
python.sdk.conda.requirements.file.not.found=Conda Environment.yml file is not found
cannot.find.executable=Cannot find executable "{0}" in {1}
python.uv.lockfile.out.of.sync=The lock file at `uv.lock` is out of sync
python.uv.update.lock=Update uv lock

View File

@@ -3,11 +3,9 @@ package com.intellij.python.hatch
import com.intellij.ide.util.PropertiesComponent
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.LocalEelApi
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.eel.where
import com.intellij.python.hatch.runtime.getHatchCommand
import com.jetbrains.python.Result
import com.jetbrains.python.sdk.detectTool
import java.nio.file.Path
import kotlin.io.path.isExecutable
@@ -40,10 +38,6 @@ object HatchConfiguration {
return result
}
suspend fun detectHatchExecutable(eelApi: EelApi): Path? {
val hatchCommand = eelApi.getHatchCommand()
val hatchPath = eelApi.exec.where(hatchCommand)?.asNioPath()
return hatchPath
}
suspend fun detectHatchExecutable(eelApi: EelApi): Path? = detectTool("hatch", eelApi).successOrNull
}

View File

@@ -1,9 +0,0 @@
package com.intellij.python.hatch.runtime
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.EelPlatform
fun EelApi.getHatchCommand(): String = when (platform) {
is EelPlatform.Windows -> "hatch.exe"
else -> "hatch"
}

View File

@@ -82,3 +82,5 @@ path.validation.ends.with.whitespace=Path ends with a whitespace
path.validation.file.not.found=File {0} is not found
path.validation.invalid=Path is invalid: {0}
path.validation.inaccessible=Path is inaccessible
cannot.find.executable=Cannot find executable "{0}" in {1}

View File

@@ -0,0 +1,81 @@
// 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.sdk
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.environmentVariables
import com.intellij.platform.eel.fs.getPath
import com.intellij.platform.eel.isWindows
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.where
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.sdk.impl.PySdkBundle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
import kotlin.io.path.isExecutable
import kotlin.io.path.pathString
/**
* Detects the path to a CLI tool executable in the given Eel environment.
*
* Search order (first match wins):
* - [EelApi.exec.where] for the given [toolName].
* - User-local locations depending on the target platform:
* - Unix-like: `~/.local/bin/<toolName>`
* - Windows: `%APPDATA%/Python/Scripts/<toolName>.exe`
* - Provided [additionalSearchPaths], with each candidate resolved as `<path>/<toolName>` (or
* `<toolName>.exe|.bat` on Windows).
*
* Notes:
* - Entries in [additionalSearchPaths] must belong to the same Eel descriptor as [eel];
* paths with a different descriptor are skipped and a warning is logged.
*
* @param toolName Name of the tool to locate (without an extension).
* @param eel Eel environment to search in; defaults to the local Eel.
* @param additionalSearchPaths Extra directories to probe, must be on the same descriptor as [eel].
* @return [PyResult] containing the resolved executable [Path] on success; otherwise a localized error
* explaining that the executable could not be found on the target machine.
*/
@ApiStatus.Internal
suspend fun detectTool(
toolName: String,
eel: EelApi = localEel,
additionalSearchPaths: List<Path> = listOf(),
): PyResult<Path> = withContext(Dispatchers.IO) {
val binary = eel.exec.where(toolName)?.asNioPath()
if (binary != null) {
return@withContext PyResult.success(binary)
}
val binaryName = if (eel.platform.isWindows) "$toolName.exe" else toolName
val paths = buildList {
if (eel.platform.isWindows) addWindowsPaths(eel, binaryName) else addUnixPaths(eel, binaryName)
additionalSearchPaths.forEach {
if (it.getEelDescriptor() != eel.descriptor) {
fileLogger().warn("Additional search paths should be on the same descriptor as Eel API, skipping ${it.pathString}")
}
else add(it.resolve(binaryName))
}
}
paths.firstOrNull { it.isExecutable() }?.let { PyResult.success(it) }
?: PyResult.localizedError(PySdkBundle.message("cannot.find.executable", toolName, localEel.descriptor.machine.name))
}
private fun MutableList<Path>.addUnixPaths(eel: EelApi, binaryName: String) {
add(eel.userInfo.home.asNioPath().resolve(Path.of(".local", "bin", binaryName)))
}
private suspend fun MutableList<Path>.addWindowsPaths(eel: EelApi, binaryName: String) {
val env = eel.exec.environmentVariables().eelIt().await()
val envsToCheck = listOf("APPDATA", "LOCALAPPDATA")
envsToCheck.forEach { envToCheck ->
env[envToCheck]?.let {
add(eel.fs.getPath(it).asNioPath().resolve(Path.of("Python", "Scripts", binaryName)))
}
}
}

View File

@@ -33,7 +33,7 @@ internal class HatchPackageManager(project: Project, sdk: Sdk) : PipPythonPackag
}
internal class HatchPackageManagerProvider : PythonPackageManagerProvider {
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? = when {
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? = when {
sdk.isHatch -> HatchPackageManager(project, sdk)
else -> null
}

View File

@@ -10,7 +10,7 @@ import org.jetbrains.annotations.ApiStatus
@ApiStatus.Experimental
class CondaPackageManagerProvider : PythonPackageManagerProvider {
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? =
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? =
if (sdk.isCondaVirtualEnv) createCondaPackageManager(project, sdk) else null
private fun createCondaPackageManager(project: Project, sdk: Sdk): PythonPackageManager =

View File

@@ -13,6 +13,7 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.Key
import com.intellij.util.cancelOnDispose
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.messages.Topic
import com.jetbrains.python.NON_INTERACTIVE_ROOT_TRACE_CONTEXT
import com.jetbrains.python.errorProcessing.PyResult
@@ -56,7 +57,6 @@ abstract class PythonPackageManager(val project: Project, val sdk: Sdk) : Dispos
}
@ApiStatus.Internal
@Volatile
protected var installedPackages: List<PythonPackage> = emptyList()
@@ -228,7 +228,7 @@ abstract class PythonPackageManager(val project: Project, val sdk: Sdk) : Dispos
@ApiStatus.Internal
open suspend fun extractDependencies(): PyResult<List<PythonPackage>>? = null
@ApiStatus.Internal
@ApiStatus.Internal
suspend fun waitForInit() {
initializationJob?.join()
if (shouldBeInitInstantly()) {
@@ -256,18 +256,18 @@ abstract class PythonPackageManager(val project: Project, val sdk: Sdk) : Dispos
private fun shouldBeInitInstantly(): Boolean = ApplicationManager.getApplication().isUnitTestMode
companion object {
@RequiresBackgroundThread
fun forSdk(project: Project, sdk: Sdk): PythonPackageManager {
val pythonPackageManagerService = project.service<PythonPackageManagerService>()
val manager = pythonPackageManagerService.forSdk(project, sdk)
return runBlockingMaybeCancellable {
val manager = pythonPackageManagerService.forSdk(project, sdk)
if (manager.shouldBeInitInstantly()) {
runBlockingMaybeCancellable {
if (manager.shouldBeInitInstantly()) {
manager.initInstalledPackages()
}
}
return manager
manager
}
}
@Topic.AppLevel
@@ -277,7 +277,7 @@ abstract class PythonPackageManager(val project: Project, val sdk: Sdk) : Dispos
@ApiStatus.Internal
data class PackageManagerErrorMessage(
@param:Nls val descriptionMessage: String,
@param:Nls val fixCommandMessage: String
@param:Nls val fixCommandMessage: String,
)
}
}

View File

@@ -16,7 +16,7 @@ interface PythonPackageManagerProvider {
* package management files etc.
* Sdk is expected to be a Python Sdk and have PythonSdkAdditionalData.
*/
fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager?
suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager?
companion object {
val EP_NAME = ExtensionPointName.create<PythonPackageManagerProvider>("Pythonid.pythonPackageManagerProvider")
@@ -26,7 +26,7 @@ interface PythonPackageManagerProvider {
@ApiStatus.Internal
@ApiStatus.Experimental
interface PythonPackageManagerService {
fun forSdk(project: Project, sdk: Sdk): PythonPackageManager
suspend fun forSdk(project: Project, sdk: Sdk): PythonPackageManager
/**
* Provides an implementation bridge for Python package management operations

View File

@@ -11,26 +11,30 @@ import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import com.jetbrains.python.sdk.PythonSdkAdditionalData
import com.jetbrains.python.sdk.getOrCreateAdditionalData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import java.util.*
import java.util.concurrent.ConcurrentHashMap
internal class PythonPackageManagerServiceImpl(private val serviceScope: CoroutineScope) : PythonPackageManagerService, Disposable {
private val cache = ConcurrentHashMap<UUID, PythonPackageManager>()
private val cache = ConcurrentHashMap<UUID, Deferred<PythonPackageManager>>()
private val bridgeCache = ConcurrentHashMap<UUID, PythonPackageManagementServiceBridge>()
/**
* Requires Sdk to be Python Sdk and have PythonSdkAdditionalData.
*/
override fun forSdk(project: Project, sdk: Sdk): PythonPackageManager {
override suspend fun forSdk(project: Project, sdk: Sdk): PythonPackageManager {
val cacheKey = (sdk.getOrCreateAdditionalData()).uuid
return cache.computeIfAbsent(cacheKey) {
val createdSdk = PythonPackageManagerProvider.EP_NAME.extensionList.firstNotNullOf { it.createPackageManagerForSdk(project, sdk) }
Disposer.register(PyPackageCoroutine.getInstance(project), createdSdk)
PythonRequirementTxtSdkUtils.migrateRequirementsTxtPathFromModuleToSdk(project, sdk)
createdSdk
}
serviceScope.async {
val createdSdk = PythonPackageManagerProvider.EP_NAME.extensionList.firstNotNullOf { it.createPackageManagerForSdk(project, sdk) }
Disposer.register(PyPackageCoroutine.getInstance(project), createdSdk)
PythonRequirementTxtSdkUtils.migrateRequirementsTxtPathFromModuleToSdk(project, sdk)
createdSdk
}
}.await()
}
override fun bridgeForSdk(project: Project, sdk: Sdk): PythonPackageManagementServiceBridge {

View File

@@ -10,7 +10,7 @@ import org.jetbrains.annotations.ApiStatus
@ApiStatus.Experimental
@ApiStatus.Internal
class PipPackageManagerProvider : PythonPackageManagerProvider {
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager {
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager {
return PipPythonPackageManager(project, sdk)
}
}

View File

@@ -10,7 +10,7 @@ import org.jetbrains.annotations.ApiStatus
@ApiStatus.Internal
class PipEnvPackageManagerProvider : PythonPackageManagerProvider {
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? =
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? =
if (sdk.isPipEnv) PipEnvPackageManager(project, sdk) else null
}

View File

@@ -54,6 +54,7 @@ import com.intellij.remote.RemoteSdkProperties;
import com.intellij.remote.TargetAwarePathMappingProvider;
import com.intellij.util.PathMappingSettings;
import com.intellij.util.PlatformUtils;
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread;
import com.intellij.util.concurrency.annotations.RequiresEdt;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.execution.ParametersListUtil;
@@ -366,7 +367,7 @@ public abstract class PythonCommandLineState extends CommandLineState {
if (sdk != null && getEnableRunTool()) {
PyRunToolProvider runToolProvider = PyRunToolProvider.forSdk(sdk);
if (runToolProvider != null && useRunTool(myConfig, sdk)) {
runToolParameters = runToolProvider.getRunToolParameters();
runToolParameters = PythonCommandLineStateExKt.getRunToolParametersForJvm(runToolProvider);
PyRunToolUsageCollector.logRun(myConfig.getProject(), PyRunToolIds.idOf(runToolProvider));
}
}
@@ -626,6 +627,7 @@ public abstract class PythonCommandLineState extends CommandLineState {
* @param helpersAwareRequest the request
* @return the representation of Python script or module execution
*/
@RequiresBackgroundThread
protected @NotNull PythonExecution buildPythonExecution(@NotNull HelpersAwareTargetEnvironmentRequest helpersAwareRequest) {
throw new UnsupportedOperationException("The implementation of Run Configuration based on Targets API is absent");
}

View File

@@ -0,0 +1,15 @@
// 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.run
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.jetbrains.python.run.features.PyRunToolParameters
import com.jetbrains.python.run.features.PyRunToolProvider
import org.jetbrains.annotations.ApiStatus
/**
* To be used by [PythonCommandLineState] only
*/
@ApiStatus.Internal
@RequiresBackgroundThread
fun PyRunToolProvider.getRunToolParametersForJvm(): PyRunToolParameters = runBlockingMaybeCancellable { getRunToolParameters() }

View File

@@ -2,8 +2,10 @@
package com.jetbrains.python.run.features
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import org.jetbrains.annotations.ApiStatus
import org.jetbrains.annotations.Nls
import org.jetbrains.annotations.NonNls
@@ -35,7 +37,7 @@ data class PyRunToolData(
@param:NonNls val id: PyRunToolId,
@param:Nls val name: String,
@param:Nls val group: String,
@param:NonNls val idForStatistics: String = id.value
@param:NonNls val idForStatistics: String = id.value,
)
/**
@@ -69,7 +71,7 @@ interface PyRunToolProvider {
* Represents the parameters required to configure and run a Python tool.
* This includes the path to the executable and a list of associated arguments.
*/
val runToolParameters: PyRunToolParameters
suspend fun getRunToolParameters(): PyRunToolParameters
/**
* Represents the initial state of the tool, determining whether it is enabled or not by default.
@@ -82,13 +84,16 @@ interface PyRunToolProvider {
* @param sdk the SDK to check the availability for
* @return true if the tool is available for the specified SDK, false otherwise
*/
fun isAvailable(sdk: Sdk): Boolean
suspend fun isAvailable(sdk: Sdk): Boolean
companion object {
@JvmField
val EP: ExtensionPointName<PyRunToolProvider> = ExtensionPointName.create("Pythonid.pyRunToolProvider")
@JvmStatic
fun forSdk(sdk: Sdk): PyRunToolProvider? = EP.extensionList.firstOrNull { it.isAvailable(sdk) }
@RequiresBackgroundThread
fun forSdk(sdk: Sdk): PyRunToolProvider? = runBlockingMaybeCancellable {
EP.extensionList.firstOrNull { it.isAvailable(sdk) }
}
}
}

View File

@@ -10,9 +10,7 @@ import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.io.FileUtil
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.eel.where
import com.intellij.python.community.execService.*
import com.intellij.python.community.execService.python.validatePythonAndGetInfo
import com.intellij.python.community.services.internal.impl.VanillaPythonWithPythonInfoImpl
@@ -188,9 +186,7 @@ sealed interface FileSystem<P : PathHolder> {
return pythonHome.path.resolvePythonBinary()?.let { PathHolder.Eel(it) }
}
override suspend fun which(cmd: String): PathHolder.Eel? {
return eelApi.exec.where(cmd)?.asNioPath()?.let { PathHolder.Eel(it) }
}
override suspend fun which(cmd: String): PathHolder.Eel? = detectTool(cmd, eelApi).mapSuccess { PathHolder.Eel(it) }.successOrNull
}
data class Target(

View File

@@ -161,7 +161,7 @@ internal class PythonSdkPanelBuilderAndSdkCreator(
}
}
private fun initialize(scope: CoroutineScope) {
private suspend fun initialize(scope: CoroutineScope) {
model.initialize(scope)
pythonBaseVersionComboBox.initialize(scope, model.baseInterpreters)

View File

@@ -8,8 +8,6 @@ import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.diagnostic.rethrowControlFlowException
import com.intellij.openapi.observable.properties.ObservableMutableProperty
import com.intellij.openapi.observable.properties.PropertyGraph
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.EelPlatform
import com.intellij.platform.eel.provider.localEel
import com.jetbrains.python.PyBundle.message
import com.jetbrains.python.conda.loadLocalPythonCondaPath
@@ -52,7 +50,7 @@ class CondaViewModel<P : PathHolder>(
}
}
fileSystem.which(eelApi.getCondaCommand())?.let { return@ToolValidator it }
fileSystem.which("conda")?.let { return@ToolValidator it }
// legacy slow fallback detection via the defined list of paths in case of there is no conda on the PATH (PY-85060),
// not sure if it is worth it to keep it, because if there is no conda on the PATH the installation might be broken
@@ -115,11 +113,3 @@ class CondaViewModel<P : PathHolder>(
return@withContext PyResult.success(environments)
}
}
/**
* correctly detects the platform only for eelApi, for targets Windows is not supported
*/
private fun EelApi?.getCondaCommand(): String = when {
this?.platform is EelPlatform.Windows -> "conda.bat"
else -> "conda"
}

View File

@@ -36,14 +36,14 @@ internal class UvInterpreterSection(
}.visibleIf(_uv)
}
fun onShown(scope: CoroutineScope) {
suspend fun onShown(scope: CoroutineScope) {
selectUvIfExists()
uvCreator.onShown(scope)
}
fun hintVisiblePredicate() = _uv and model.uvViewModel.uvExecutable.isNotNull()
private fun selectUvIfExists() {
private suspend fun selectUvIfExists() {
if (PropertiesComponent.getInstance().getValue(FAV_MODE) != null) return
if (hasUvExecutable() && selectedMode.get() != PythonInterpreterSelectionMode.PROJECT_UV) {
selectedMode.set(PythonInterpreterSelectionMode.PROJECT_UV)

View File

@@ -4,10 +4,8 @@ package com.jetbrains.python.sdk.pipenv
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.progress.runBlockingCancellable
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.util.SystemInfo
import com.intellij.platform.eel.provider.asNioPath
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.intellij.platform.eel.where
import com.intellij.python.community.execService.ProcessOutputTransformer
import com.intellij.python.community.execService.ZeroCodeStdoutTransformer
import com.intellij.python.community.impl.pipenv.pipenvPath
@@ -17,6 +15,7 @@ import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.getOrNull
import com.jetbrains.python.sdk.add.v2.PathHolder
import com.jetbrains.python.sdk.createSdk
import com.jetbrains.python.sdk.detectTool
import com.jetbrains.python.sdk.runExecutableWithProgress
import com.jetbrains.python.venvReader.VirtualEnvReader
import kotlinx.coroutines.Dispatchers
@@ -41,18 +40,7 @@ suspend fun <T> runPipEnv(dirPath: Path?, vararg args: String, transformer: Proc
* Detects the pipenv executable in `$PATH`.
*/
@Internal
suspend fun detectPipEnvExecutable(): PyResult<Path> {
val name = when {
SystemInfo.isWindows -> "pipenv.exe"
else -> "pipenv"
}
val executablePath = localEel.exec.where(name)?.asNioPath()
if (executablePath == null) {
return PyResult.localizedError(PyBundle.message("cannot.find.executable", name, localEel.descriptor.machine.name))
}
return PyResult.success(executablePath)
}
suspend fun detectPipEnvExecutable(eel: EelApi = localEel): PyResult<Path> = detectTool("pipenv", eel)
@Internal
fun detectPipEnvExecutableOrNull(): Path? {
@@ -121,4 +109,4 @@ private suspend fun setUpPipEnv(moduleBasePath: Path, basePythonBinaryPath: Pyth
VirtualEnvReader.Instance.findPythonInPythonRoot(Path.of(pipEnv))?.toString()
} ?: return PyResult.localizedError(PyBundle.message("python.sdk.provided.path.is.invalid", pipEnv))
return PyResult.success(Path.of(pipEnvExecutablePathString))
}
}

View File

@@ -1,7 +1,6 @@
// 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.sdk.poetry
import com.intellij.execution.Platform
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.module.Module
import com.intellij.openapi.projectRoots.Sdk
@@ -11,7 +10,6 @@ import com.intellij.platform.eel.EelApi
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.systemOs
import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.ExecService
@@ -25,10 +23,7 @@ import com.jetbrains.python.packaging.PyRequirement
import com.jetbrains.python.packaging.PyRequirementParser
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
import com.jetbrains.python.packaging.common.PythonPackage
import com.jetbrains.python.sdk.PyDetectedSdk
import com.jetbrains.python.sdk.associatedModulePath
import com.jetbrains.python.sdk.basePath
import com.jetbrains.python.sdk.runExecutableWithProgress
import com.jetbrains.python.sdk.*
import com.jetbrains.python.venvReader.VirtualEnvReader
import io.github.z4kn4fein.semver.Version
import io.github.z4kn4fein.semver.toVersion
@@ -61,24 +56,9 @@ suspend fun runPoetry(projectPath: Path?, vararg args: String): PyResult<String>
/**
* Detects the poetry executable in `$PATH`.
*/
internal suspend fun detectPoetryExecutable(eel: EelApi = localEel): PyResult<Path> {
val windows = eel.systemOs().platform == Platform.WINDOWS
val poetryBinNames = if (windows) {
setOf("poetry.exe", "poetry.bat")
}
else {
setOf("poetry")
}
internal suspend fun detectPoetryExecutable(eel: EelApi = localEel): PyResult<Path> =
// TODO: Poetry from store isn't detected because local eel doesn't obey appx binaries. We need to fix it on eel side
val userHomePoetry = eel.userInfo.home.resolve(".poetry").resolve(".bin")
val executablePath = withContext(Dispatchers.IO) {
poetryBinNames.flatMap { eel.exec.findExeFilesInPath(it) }.firstOrNull()?.asNioPath()
?: poetryBinNames.map { userHomePoetry.resolve(it).asNioPath() }.firstOrNull { it.exists() }
}
return executablePath?.let { PyResult.success(it) } ?: PyResult.localizedError(poetryNotFoundException)
}
detectTool("poetry", eel, listOf(eel.userInfo.home.asNioPath().resolve(Path.of(".poetry", ".bin"))))
/**
* Returns the configured poetry executable or detects it automatically.

View File

@@ -10,5 +10,5 @@ import com.jetbrains.python.packaging.management.PythonPackageManagerProvider
*/
class PoetryPackageManagerProvider : PythonPackageManagerProvider {
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? = if (sdk.isPoetry) PoetryPackageManager(project, sdk) else null
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? = if (sdk.isPoetry) PoetryPackageManager(project, sdk) else null
}

View File

@@ -70,9 +70,9 @@ internal class UvPackageManager(project: Project, sdk: Sdk, private val uv: UvLo
return it
}.map { it.name }
val categorizedPackages = packages
val categorizedPackages = packages
.map { PyPackageName.from(it) }
.partition { it.name !in dependencyNames || sdk.uvUsePackageManagement }
.partition { it.name !in dependencyNames || sdk.uvUsePackageManagement }
return PyResult.success(categorizedPackages)
}
@@ -128,7 +128,7 @@ internal class UvPackageManager(project: Project, sdk: Sdk, private val uv: UvLo
}
class UvPackageManagerProvider : PythonPackageManagerProvider {
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? {
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager? {
if (!sdk.isUv) {
return null
}

View File

@@ -1,16 +1,16 @@
// 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.sdk.uv.impl
import com.intellij.execution.configurations.PathEnvironmentVariableUtil
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.ui.ValidationInfo
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.SystemProperties
import com.intellij.platform.eel.EelApi
import com.intellij.platform.eel.provider.localEel
import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.pathValidation.PlatformAndRoot
import com.jetbrains.python.pathValidation.ValidationRequest
import com.jetbrains.python.pathValidation.validateExecutableFile
import com.jetbrains.python.sdk.detectTool
import com.jetbrains.python.sdk.runExecutableWithProgress
import com.jetbrains.python.sdk.uv.UvCli
import kotlinx.coroutines.CoroutineDispatcher
@@ -54,42 +54,21 @@ private class UvCliImpl(val dispatcher: CoroutineDispatcher, val uv: Path) : UvC
}
}
fun detectUvExecutable(): Path? {
val name = when {
SystemInfo.isWindows -> "uv.exe"
else -> "uv"
}
suspend fun detectUvExecutable(eel: EelApi): Path? = detectTool("uv", eel).successOrNull
val binary = PathEnvironmentVariableUtil.findInPath(name)?.toPath()
if (binary != null) {
return binary
}
val userHome = SystemProperties.getUserHome()
val appData = if (SystemInfo.isWindows) System.getenv("APPDATA") else null
val paths = mutableListOf<Path>().apply {
add(Path.of(userHome, ".local", "bin", name))
if (appData != null) {
add(Path.of(appData, "Python", "Scripts", name))
}
}
return paths.firstOrNull { it.exists() }
}
fun getUvExecutable(): Path? {
return PropertiesComponent.getInstance().uvPath?.takeIf { it.exists() } ?: detectUvExecutable()
suspend fun getUvExecutable(eel: EelApi = localEel): Path? {
return PropertiesComponent.getInstance().uvPath?.takeIf { it.exists() } ?: detectUvExecutable(eel)
}
fun setUvExecutable(path: Path) {
PropertiesComponent.getInstance().uvPath = path
}
fun hasUvExecutable(): Boolean {
suspend fun hasUvExecutable(): Boolean {
return getUvExecutable() != null
}
fun createUvCli(uv: Path? = null, dispatcher: CoroutineDispatcher = Dispatchers.IO): PyResult<UvCli> {
suspend fun createUvCli(uv: Path? = null, dispatcher: CoroutineDispatcher = Dispatchers.IO): PyResult<UvCli> {
val path = uv ?: getUvExecutable()
val error = validateUvExecutable(path)
return if (error != null) {

View File

@@ -6,8 +6,6 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.jetbrains.python.PyBundle
import com.jetbrains.python.errorProcessing.*
import com.jetbrains.python.errorProcessing.PyExecResult
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.onFailure
import com.jetbrains.python.packaging.PyPackageName
import com.jetbrains.python.packaging.common.PythonOutdatedPackage
@@ -278,7 +276,7 @@ private class UvLowLevelImpl(val cwd: Path, private val uvCli: UvCli) : UvLowLev
override suspend fun sync(): PyResult<String> {
return uvCli.runUv(cwd, "sync")
}
}
override suspend fun lock(): PyResult<String> {
return uvCli.runUv(cwd, "lock")
@@ -318,7 +316,7 @@ fun createUvLowLevel(cwd: Path, uvCli: UvCli): UvLowLevel {
return UvLowLevelImpl(cwd, uvCli)
}
fun createUvLowLevel(cwd: Path): PyResult<UvLowLevel> = createUvCli().mapSuccess { createUvLowLevel(cwd, it) }
suspend fun createUvLowLevel(cwd: Path): PyResult<UvLowLevel> = createUvCli().mapSuccess { createUvLowLevel(cwd, it) }
private fun tryExtractStderr(err: PyError): String? =
when (err) {

View File

@@ -16,7 +16,7 @@ import kotlin.io.path.pathString
@ApiStatus.Internal
@RequiresBackgroundThread(generateAssertion = false)
fun buildUvRunConfigurationCli(options: UvRunConfigurationOptions, isDebug: Boolean): PythonExecution {
suspend fun buildUvRunConfigurationCli(options: UvRunConfigurationOptions, isDebug: Boolean): PythonExecution {
val toolPath = requireNotNull(getUvExecutable()) { "Unable to find uv executable." }
val toolParams = mutableListOf("run")
@@ -47,9 +47,11 @@ fun buildUvRunConfigurationCli(options: UvRunConfigurationOptions, isDebug: Bool
toolPath.pathString,
options.scriptOrModule
) + toolParams
} else if (!isDebug) {
}
else if (!isDebug) {
toolParams + "--script"
} else {
}
else {
toolParams
},
pythonScriptPath = constant(Path.of(options.scriptOrModule))

View File

@@ -5,8 +5,10 @@ import com.intellij.execution.runners.ExecutionEnvironment
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.intellij.openapi.project.Project
import com.intellij.platform.ide.progress.runWithModalProgressBlocking
import com.intellij.util.concurrency.annotations.RequiresBackgroundThread
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.jetbrains.python.PyBundle
import com.jetbrains.python.Result
@@ -41,9 +43,15 @@ class UvRunConfigurationState(
)
}
override fun buildPythonExecution(helpersAwareRequest: HelpersAwareTargetEnvironmentRequest): PythonExecution {
return buildUvRunConfigurationCli(uvRunConfiguration.options, isDebug)
}
/**
* I'd prefer this whole function to be suspendable, but unfortunately it's an override from Java code, so we have to use
* [runBlockingMaybeCancellable] here :(
*/
@RequiresBackgroundThread
override fun buildPythonExecution(helpersAwareRequest: HelpersAwareTargetEnvironmentRequest): PythonExecution =
runBlockingMaybeCancellable {
buildUvRunConfigurationCli(uvRunConfiguration.options, isDebug)
}
}
@ApiStatus.Internal
@@ -63,12 +71,12 @@ fun canRun(
}
val workingDirectory = options.workingDirectory
val uvExecutable = getUvExecutable()
var isError = false
var isUnsynced = false
runWithModalProgressBlocking(project, PyBundle.message("uv.run.configuration.state.progress.name")) {
val uvExecutable = getUvExecutable()
if (workingDirectory != null && uvExecutable != null) {
runWithModalProgressBlocking(project, PyBundle.message("uv.run.configuration.state.progress.name")) {
if (workingDirectory != null && uvExecutable != null) {
val uv = createUvCli(uvExecutable).mapSuccess { createUvLowLevel(workingDirectory, it) }.getOrNull()
when (uv?.let { requiresSync(it, options, logger) }?.getOrNull()) {
@@ -77,9 +85,9 @@ fun canRun(
null -> isError = true
}
}
}
else {
isError = true
else {
isError = true
}
}
if (isError || isUnsynced) {

View File

@@ -9,30 +9,43 @@ import com.jetbrains.python.run.features.PyRunToolParameters
import com.jetbrains.python.run.features.PyRunToolProvider
import com.jetbrains.python.sdk.uv.impl.getUvExecutable
import com.jetbrains.python.sdk.uv.isUv
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* PyRunToolProvider implementation that runs scripts/modules using `uv run`.
*/
private class UvRunToolProvider : PyRunToolProvider {
override suspend fun getRunToolParameters(): PyRunToolParameters {
if (!runToolParameters.isCompleted) {
runToolParametersMutex.withLock {
if (!runToolParameters.isCompleted) {
runToolParameters.complete(
PyRunToolParameters(requireNotNull(getUvExecutable()?.toString()) { "Unable to find uv executable." }, listOf("run"))
)
}
}
}
return runToolParameters.await()
}
override val runToolData: PyRunToolData = PyRunToolData(
PyRunToolId("uv.run"),
PyBundle.message("uv.run.configuration.type.display.name"),
PyBundle.message("python.run.configuration.fragments.python.group"),
)
override val initialToolState: Boolean = true
override suspend fun isAvailable(sdk: Sdk): Boolean = sdk.isUv && getUvExecutable() != null
/**
* We use runToolParameters only if a tool provider is available. So we need to have a lazy initialization here
* to construct these parameters iff the validation has passed.
*/
override val runToolParameters: PyRunToolParameters by lazy {
PyRunToolParameters(
requireNotNull(getUvExecutable()?.toString()) { "Unable to find uv executable." },
listOf("run")
)
}
override val initialToolState: Boolean = true
override fun isAvailable(sdk: Sdk): Boolean = sdk.isUv && getUvExecutable() != null
private val runToolParameters = CompletableDeferred<PyRunToolParameters>()
private val runToolParametersMutex = Mutex()
}

View File

@@ -35,7 +35,7 @@ class TestPackageManagerProvider : PythonPackageManagerProvider {
}
override fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager {
override suspend fun createPackageManagerForSdk(project: Project, sdk: Sdk): PythonPackageManager {
return TestPythonPackageManager(project, sdk)
.withPackageNames(packageNames)
.withPackageDetails(packageDetails)

View File

@@ -15,7 +15,7 @@ import org.jetbrains.annotations.TestOnly
@TestOnly
class TestPythonPackageManagerService(val installedPackages: List<PythonPackage> = emptyList()) : PythonPackageManagerService {
override fun forSdk(project: Project, sdk: Sdk): PythonPackageManager {
override suspend fun forSdk(project: Project, sdk: Sdk): PythonPackageManager {
installedPackages.ifEmpty {
return TestPythonPackageManager(project, sdk).also { Disposer.register(project, it) }
}