PY-85903 Implement a cache for conda environments

GitOrigin-RevId: 78338ac3ffc7f25a6ba980cd017f0c8c0602bc6b
This commit is contained in:
Alexey Katsman
2025-12-08 19:26:13 +00:00
committed by intellij-monorepo-bot
parent ec20742361
commit ddc3157c60
8 changed files with 147 additions and 49 deletions
@@ -199,7 +199,7 @@ internal class PyEnvironmentYmlSdkConfiguration : PyProjectSdkConfigurationExten
private suspend fun createCondaEnv(project: Project, condaExecutable: String, environmentYml: String): PyResult<Sdk> {
val binaryToExec = BinOnEel(Path.of(condaExecutable))
val existingEnvs = PyCondaEnv.getEnvs(binaryToExec).getOrNull() ?: emptyList()
val existingEnvs = PyCondaEnv.getEnvs(binaryToExec, forceRefresh = true).getOrNull() ?: emptyList()
val existingSdks = PyConfigurableInterpreterList.getInstance(project).model.sdks
val newCondaEnvInfo = NewCondaEnvRequest.LocalEnvByLocalEnvironmentFile(Path.of(environmentYml),
@@ -35,7 +35,9 @@
implementationClass="com.jetbrains.python.sdk.poetry.PoetryPackageVersionsInspection"
key="INSP.poetry.package.versions.display.name" bundle="messages.PyBundle" groupKey="INSP.GROUP.python"
suppressId="PoetryPackageVersions" shortName="PoetryPackageVersionsInspection"/>
<registryKey defaultValue="10" description="How ofter system pythons must be refreshed (minutes)" key="python.system.refresh.minutes"/>
<registryKey defaultValue="10" description="How often system pythons must be refreshed (minutes)" key="python.system.refresh.minutes" restartRequired="true"/>
<registryKey defaultValue="20" description="How often conda environments cache must be refreshed (seconds)" key="python.conda.envs.refresh.seconds" restartRequired="true"/>
<registryKey defaultValue="60" description="TTL of conda environments cache (seconds)" key="python.conda.envs.cache.ttl.seconds" restartRequired="true"/>
<!-- Only modules with `baseDir` support Python SDKs -->
<registryKey defaultValue="true" description="Show non-python modules(different type, no python facet) in Python configurable when they have base dir" key="python.show.modules.with.base.dir" restartRequired="true"/>
<fileType name="Requirements.txt"
@@ -26,6 +26,7 @@ suspend fun <T> runExecutableWithProgress(
env: Map<String, String> = emptyMap(),
vararg args: String,
transformer: ProcessOutputTransformer<T>,
execService: ExecService = ExecService(),
processWeight: ConcurrentProcessWeight = ConcurrentProcessWeight.LIGHT
): PyResult<T> {
val execOptions = ExecOptions(timeout = timeout, env = env, weight = processWeight)
@@ -37,7 +38,7 @@ suspend fun <T> runExecutableWithProgress(
}
}
return ExecService().execute(
return execService.execute(
binary = binaryToExec,
args = Args(*args),
options = execOptions,
@@ -122,7 +122,7 @@ internal class CondaExistingEnvironmentSelector<P : PathHolder>(model: PythonAdd
reloadLink.action = object : AbstractAction(message("sdk.create.custom.conda.refresh.envs")) {
override fun actionPerformed(e: ActionEvent?) {
model.condaViewModel.detectCondaEnvironments()
model.condaViewModel.detectCondaEnvironments(forceRefresh = true)
}
}
@@ -89,15 +89,15 @@ class CondaViewModel<P : PathHolder>(
baseCondaEnv.set(null)
if (condaExecutable?.validationResult?.successOrNull != null) {
detectCondaEnvironments()
detectCondaEnvironments(forceRefresh = false)
}
}
}
fun detectCondaEnvironments() {
fun detectCondaEnvironments(forceRefresh: Boolean) {
condaEnvironmentsLoading.value = true
scope.launch(Dispatchers.UI) {
condaEnvironmentsResult.value = updateCondaEnvironments()
condaEnvironmentsResult.value = updateCondaEnvironments(forceRefresh)
}.invokeOnCompletion {
condaEnvironmentsLoading.value = false
}
@@ -133,13 +133,13 @@ class CondaViewModel<P : PathHolder>(
path.refreshAndFindVirtualFileOrDirectory()?.takeIf { virtualFile -> virtualFile.isFile }
}
private suspend fun updateCondaEnvironments(): PyResult<List<PyCondaEnv>> = withContext(Dispatchers.IO) {
private suspend fun updateCondaEnvironments(forceRefresh: Boolean): PyResult<List<PyCondaEnv>> = withContext(Dispatchers.IO) {
val executable = condaExecutable.get()
if (executable == null) return@withContext PyResult.localizedError(message("python.sdk.conda.no.exec"))
executable.validationResult.getOr { return@withContext it }
val binaryToExec = executable.pathHolder?.let { fileSystem.getBinaryToExec(it) }!!
val environments = PyCondaEnv.getEnvs(binaryToExec).getOr { return@withContext it }
val environments = PyCondaEnv.getEnvs(binaryToExec, forceRefresh).getOr { return@withContext it }
val baseConda = environments.find { env -> env.envIdentity.let { it is PyCondaEnvIdentity.UnnamedEnv && it.isBase } }
withContext(Dispatchers.UI) {
@@ -57,10 +57,11 @@ object CondaExecutor {
) { PyResult.success(Unit) }
}
suspend fun listEnvs(binaryToExec: BinaryToExec): PyResult<CondaEnvInfo> {
suspend fun listEnvs(binaryToExec: BinaryToExec, execService: ExecService = ExecService()): PyResult<CondaEnvInfo> {
val args = listOf("env", "list", "--json")
return runConda(
binaryToExec, args, null,
execService = execService,
transformer = ZeroCodeJsonParserTransformer { CondaExecutionParser.parseListEnvironmentsOutput(it) }
)
}
@@ -129,11 +130,20 @@ object CondaExecutor {
args: List<String>,
condaEnvIdentity: PyCondaEnvIdentity?,
timeout: Duration = 15.minutes,
execService: ExecService = ExecService(),
transformer: ProcessOutputTransformer<T>,
): PyResult<T> {
val envs = getFixedEnvs(binaryToExec).getOr { return it }
val runArgs = prepareCondaRunArgs(args, emptyList(), condaEnvIdentity).toTypedArray()
return runExecutableWithProgress(binaryToExec, timeout, env = envs, *runArgs, transformer = transformer, processWeight = ConcurrentProcessWeight.HEAVY)
return runExecutableWithProgress(
binaryToExec,
timeout,
env = envs,
*runArgs,
transformer = transformer,
execService = execService,
processWeight = ConcurrentProcessWeight.HEAVY
)
}
private fun getFixedEnvs(binaryToExec: BinaryToExec): PyResult<Map<String, String>> {
@@ -4,27 +4,28 @@ package com.jetbrains.python.sdk.flavors.conda
import com.intellij.execution.target.FullPathOnTarget
import com.intellij.execution.target.TargetEnvironmentConfiguration
import com.intellij.execution.target.TargetedCommandLineBuilder
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.BinOnTarget
import com.intellij.openapi.util.IntellijInternalApi
import com.intellij.openapi.util.registry.RegistryManager
import com.intellij.python.community.execService.BinaryToExec
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.sdk.conda.TargetCommandExecutor
import com.jetbrains.python.sdk.conda.createCondaSdkFromExistingEnv
import com.jetbrains.python.sdk.conda.execution.CondaExecutor
import com.jetbrains.python.sdk.flavors.conda.PyCondaEnv.Companion.getEnvs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
import java.util.*
import kotlin.io.path.name
import kotlin.time.Duration.Companion.seconds
/**
* TODO: Once we get rid of [TargetCommandExecutor] and have access to [TargetEnvironmentConfiguration] use it validate conda binary in [getEnvs]
* @see `PyCondaTest`
*/
@ApiStatus.Internal
data class PyCondaEnv(
val envIdentity: PyCondaEnvIdentity,
val fullCondaPathOnTarget: FullPathOnTarget,
@@ -32,39 +33,20 @@ data class PyCondaEnv(
companion object {
/**
* The logic is the following:
*
* - If an explicit refresh is triggered, ask the cache to reload the value
* - If a value is present in the cache, a refresh is triggered if the refresh interval has passed, and the old value is returned:
* - If it's an error, let's try to reload (we may succeed this time)
* - If it's a success, return this value
* - If a value is not present in the cache, it will be calculated
*
* @return list of conda environments
*/
@ApiStatus.Internal
suspend fun getEnvs(binaryToExec: BinaryToExec): PyResult<List<PyCondaEnv>> {
val condaPath = when (binaryToExec) {
is BinOnEel -> binaryToExec.path.toString()
is BinOnTarget -> binaryToExec.getLocalExePath().value
}
val info = CondaExecutor.listEnvs(binaryToExec).getOr { return it }
val condaPrefix = info.condaPrefix ?: condaPath.removeSuffix("/bin/conda")
val envs = info.envs.distinctBy { it.trim().lowercase(Locale.getDefault()) }
val identities = envs.map { envPath ->
// Env name is the basename for envs inside of default location
// envPath should be direct child of envs_dirs to be a NamedEnv
val isEnvName = info.envsDirs.any {
Path.of(it) == Path.of(envPath).parent
}
val envName = if (isEnvName)
Path.of(envPath).name
else
null
val base = envPath.equals(condaPrefix, ignoreCase = true)
val identity = if (envName != null) {
PyCondaEnvIdentity.NamedEnv(envName)
}
else {
PyCondaEnvIdentity.UnnamedEnv(envPath, base)
}
PyCondaEnv(identity, condaPath)
}
return PyResult.success(identities)
}
@JvmOverloads
suspend fun getEnvs(binaryToExec: BinaryToExec, forceRefresh: Boolean = false): PyResult<List<PyCondaEnv>> =
service<CondaEnvService>().getEnvs(binaryToExec, forceRefresh)
suspend fun createEnv(command: PyCondaCommand, newCondaEnvInfo: NewCondaEnvRequest): PyResult<Unit> {
return newCondaEnvInfo.create(command.asBinaryToExec())
@@ -98,4 +80,19 @@ data class PyCondaEnv(
}
override fun toString(): String = "$envIdentity@$fullCondaPathOnTarget"
}
}
@OptIn(IntellijInternalApi::class)
@Service(Service.Level.APP)
private class CondaEnvService(scope: CoroutineScope) {
private val _condaEnvProviderImpl: Deferred<PyCondaEnvProvider> = scope.async {
PyCondaEnvProvider(
refreshInterval = RegistryManager.getInstanceAsync().intValue("python.conda.envs.refresh.seconds").seconds,
ttlAfterWrite = RegistryManager.getInstanceAsync().intValue("python.conda.envs.cache.ttl.seconds").seconds,
)
}
private suspend fun condaEnvProvider() = _condaEnvProviderImpl.await()
suspend fun getEnvs(binaryToExec: BinaryToExec, forceRefresh: Boolean): PyResult<List<PyCondaEnv>> =
condaEnvProvider().getEnvs(binaryToExec, forceRefresh)
}
@@ -0,0 +1,88 @@
// 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.flavors.conda
import com.github.benmanes.caffeine.cache.AsyncLoadingCache
import com.github.benmanes.caffeine.cache.Caffeine
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.intellij.openapi.util.IntellijInternalApi
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.BinOnTarget
import com.intellij.python.community.execService.BinaryToExec
import com.intellij.python.community.execService.ExecService
import com.jetbrains.python.errorProcessing.PyResult
import com.jetbrains.python.isFailure
import com.jetbrains.python.isSuccess
import com.jetbrains.python.sdk.conda.execution.CondaExecutor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.future.asDeferred
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
import java.util.*
import java.util.concurrent.Executor
import kotlin.io.path.name
import kotlin.time.Duration
import kotlin.time.toJavaDuration
@ApiStatus.Internal
@IntellijInternalApi
class PyCondaEnvProvider(
refreshInterval: Duration,
ttlAfterWrite: Duration,
executor: Executor = Dispatchers.Default.asExecutor(),
private val execService: ExecService = ExecService(),
) {
private val cache: AsyncLoadingCache<BinaryToExec, PyResult<List<PyCondaEnv>>> = Caffeine.newBuilder()
.executor(executor)
.refreshAfterWrite(refreshInterval.toJavaDuration())
.expireAfterWrite(ttlAfterWrite.toJavaDuration())
.buildAsync { binaryToExec ->
runBlockingMaybeCancellable {
getEnvsInternal(binaryToExec)
}
}
suspend fun getEnvs(binaryToExec: BinaryToExec, forceRefresh: Boolean): PyResult<List<PyCondaEnv>> {
if (forceRefresh) {
return cache.synchronous().refresh(binaryToExec).asDeferred().await()
}
val currentValue = cache.getIfPresent(binaryToExec)?.asDeferred()?.await()
return when {
currentValue?.isSuccess == true -> currentValue
currentValue?.isFailure == true -> cache.synchronous().refresh(binaryToExec).asDeferred().await()
else -> cache[binaryToExec].asDeferred().await()
}
}
private suspend fun getEnvsInternal(binaryToExec: BinaryToExec): PyResult<List<PyCondaEnv>> {
val condaPath = when (binaryToExec) {
is BinOnEel -> binaryToExec.path.toString()
is BinOnTarget -> binaryToExec.getLocalExePath().value
}
val info = CondaExecutor.listEnvs(binaryToExec, execService).getOr { return it }
val condaPrefix = info.condaPrefix ?: condaPath.removeSuffix("/bin/conda")
val envs = info.envs.distinctBy { it.trim().lowercase(Locale.getDefault()) }
val identities = envs.map { envPath ->
// Env name is the basename for envs inside of default location
// envPath should be direct child of envs_dirs to be a NamedEnv
val isEnvName = info.envsDirs.any {
Path.of(it) == Path.of(envPath).parent
}
val envName = if (isEnvName)
Path.of(envPath).name
else
null
val base = envPath.equals(condaPrefix, ignoreCase = true)
val identity = if (envName != null) {
PyCondaEnvIdentity.NamedEnv(envName)
}
else {
PyCondaEnvIdentity.UnnamedEnv(envPath, base)
}
PyCondaEnv(identity, condaPath)
}
return PyResult.success(identities)
}
}