PY-85634: Make PyInterpreterInspection async for PyCharm

PyInterpreterInspection uses SDK configurators to find existing
environments and find the most suitable SDK. This operation takes time
(especially with conda on Windows), and on top it's performed on every
file change, making UI freeze all the time.

This change makes inspection asynchronous by caching the result and
triggerring inspection again to show changes on the UI.

GitOrigin-RevId: 6a64101c5b4e5c36841f0d7c046c5fce1c5fd50b
This commit is contained in:
Alexey Katsman
2025-11-25 16:27:23 +00:00
committed by intellij-monorepo-bot
parent 3221788f65
commit ab0b2158ed
4 changed files with 109 additions and 46 deletions
@@ -616,6 +616,7 @@ INSP.interpreter.python.has.reached.its.end.of.life.and.is.no.longer.supported.i
INSP.interpreter.python.has.reached.its.end.life.and.is.no.longer.supported.in.python.plugin=Python {0} has reached its end-of-life date and it is no longer supported in the Python plugin
INSP.interpreter.no.python.interpreter.configured.for.project=No Python interpreter configured for the project
INSP.interpreter.no.python.interpreter.configured.for.module=No Python interpreter configured for the module
INSP.interpreter.checking.existing.environments=Checking existing environments
# ReturnValueFromInitInspection
INSP.NAME.init.return=__init__ method that returns a value
@@ -0,0 +1,66 @@
// 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.inspections
import com.github.benmanes.caffeine.cache.Caffeine
import com.github.benmanes.caffeine.cache.LoadingCache
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer
import com.intellij.codeInspection.LocalQuickFix
import com.intellij.openapi.application.backgroundWriteAction
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.module.Module
import com.intellij.openapi.util.NlsContexts
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.util.FileContentUtilCore
import com.jetbrains.python.psi.PyFile
import kotlinx.coroutines.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
/**
* This class is intended for async file inspections. It should have the same lifecycle as the inspection, the best
* way is to create the instance of this runner as a property of the inspection itself.
*/
internal class PyAsyncFileInspectionRunner(
@NlsContexts.ProgressTitle private val progressTitle: String,
cacheTtl: Duration = 20.seconds,
cacheLoader: suspend (Module) -> List<LocalQuickFix>,
) {
private val cache: LoadingCache<Module, Deferred<List<LocalQuickFix>>> = Caffeine.newBuilder()
.expireAfterWrite(cacheTtl.toJavaDuration())
.weakKeys()
.build { module ->
val project = module.project
project.service<InspectionRunnerService>().scope.async {
withBackgroundProgress(module.project, progressTitle) {
cacheLoader(module)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun runInspection(node: PyFile, module: Module): List<LocalQuickFix>? {
val cached = cache.getIfPresent(module) != null
val fixes = cache.get(module)
if (fixes.isCompleted) {
return fixes.getCompleted()
}
if (!cached) {
fixes.invokeOnCompletion {
val project = module.project
project.service<InspectionRunnerService>().scope.launch {
backgroundWriteAction {
DaemonCodeAnalyzer.getInstance(project).restart(node, "$progressTitle finished")
}
}
}
}
return null
}
}
@Service(Service.Level.PROJECT)
private class InspectionRunnerService(val scope: CoroutineScope)
@@ -13,7 +13,7 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.module.Module
import com.intellij.openapi.module.ModuleUtilCore
import com.intellij.openapi.progress.runBlockingMaybeCancellable
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ProjectRootManager
@@ -25,6 +25,7 @@ import com.intellij.python.sdkConfigurator.common.enableSDKAutoConfigurator
import com.intellij.util.PathUtil
import com.jetbrains.python.PyPsiBundle
import com.jetbrains.python.PythonIdeLanguageCustomization
import com.jetbrains.python.inspections.PyAsyncFileInspectionRunner
import com.jetbrains.python.inspections.PyInspection
import com.jetbrains.python.inspections.PyInspectionExtension
import com.jetbrains.python.inspections.PyInspectionVisitor
@@ -40,19 +41,32 @@ import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
import com.jetbrains.python.sdk.legacy.PythonSdkUtil
import com.jetbrains.python.ui.PyUiUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.regex.Pattern
private val NAME: Pattern = Pattern.compile("Python (?<version>\\d\\.\\d+)\\s*(\\((?<name>.+?)\\))?")
class PyInterpreterInspection : PyInspection() {
class PyInterpreterInspection : PyInspection(), DumbAware {
private val asyncFileInspectionRunner = PyAsyncFileInspectionRunner(
PyPsiBundle.message("INSP.interpreter.checking.existing.environments")
) { module ->
buildList {
val sdkName = ProjectRootManager.getInstance(module.project).projectSdkName
getSuitableSdkFix(sdkName, module)?.let { add(it) }
add(ConfigureInterpreterFix())
}
}
override fun buildVisitor(holder: ProblemsHolder, isOnTheFly: Boolean, session: LocalInspectionToolSession): PsiElementVisitor =
PyInterpreterInspectionVisitor(holder, PyInspectionVisitor.getContext(session))
PyInterpreterInspectionVisitor(holder, PyInspectionVisitor.getContext(session), asyncFileInspectionRunner)
}
class PyInterpreterInspectionVisitor(
private class PyInterpreterInspectionVisitor(
holder: ProblemsHolder?,
context: TypeEvalContext,
private val asyncFileInspectionRunner: PyAsyncFileInspectionRunner,
) : PyInspectionVisitor(holder, context) {
override fun visitPyFile(node: PyFile) {
@@ -74,35 +88,29 @@ class PyInterpreterInspectionVisitor(
}
private fun registerProblemWithCommonFixes(node: PyFile, @InspectionMessage message: String, module: Module?, pyCharm: Boolean) {
val fixes = buildList {
if (module != null && pyCharm) {
val sdkName = ProjectRootManager.getInstance(node.project).projectSdkName
getSuitableSdkFix(sdkName, module)?.let { add(it) }
add(ConfigureInterpreterFix())
}
else {
add(InterpreterSettingsQuickFix(module))
if (module != null && pyCharm) {
asyncFileInspectionRunner.runInspection(node, module)?.let { fixes ->
registerProblem(node, message, *fixes.toTypedArray())
}
}
registerProblem(node, message, *fixes.toTypedArray())
else {
registerProblem(node, message, InterpreterSettingsQuickFix(module))
}
}
}
private fun getSuitableSdkFix(name: String?, module: Module): LocalQuickFix? {
private suspend fun getSuitableSdkFix(name: String?, module: Module): LocalQuickFix? = withContext(Dispatchers.Default) {
// this method is based on com.jetbrains.python.sdk.PySdkExtKt.suggestAssociatedSdkName
// please keep it in sync with the mentioned method and com.jetbrains.python.PythonSdkConfigurator.configureSdk
val existingSdks = getExistingSdks()
val associatedSdk = mostPreferred(filterAssociatedSdks(module, existingSdks))
if (associatedSdk != null) return UseExistingInterpreterFix(associatedSdk, module)
if (associatedSdk != null) return@withContext UseExistingInterpreterFix(associatedSdk, module)
val context = UserDataHolderBase()
val createSdkInfo = runBlockingMaybeCancellable {
PyProjectSdkConfigurationExtension.findAllSortedForModule(module).firstOrNull()
}
if (createSdkInfo != null) return UseProvidedInterpreterFix(module, createSdkInfo)
val createSdkInfo = PyProjectSdkConfigurationExtension.findAllSortedForModule(module).firstOrNull()
if (createSdkInfo != null) return@withContext UseProvidedInterpreterFix(module, createSdkInfo)
if (name != null) {
val matcher = NAME.matcher(name)
@@ -111,13 +119,13 @@ private fun getSuitableSdkFix(name: String?, module: Module): LocalQuickFix? {
if (venvName != null) {
val detectedAssociatedViaRootNameEnv = detectAssociatedViaRootNameEnv(venvName, module, existingSdks, context)
if (detectedAssociatedViaRootNameEnv != null) {
return UseDetectedInterpreterFix(detectedAssociatedViaRootNameEnv, existingSdks, true, module)
return@withContext UseDetectedInterpreterFix(detectedAssociatedViaRootNameEnv, existingSdks, true, module)
}
}
else {
val detectedSystemWideSdk = detectSystemWideSdk(matcher.group("version"), module, existingSdks, context)
if (detectedSystemWideSdk != null) {
return UseDetectedInterpreterFix(detectedSystemWideSdk, existingSdks, false, module)
return@withContext UseDetectedInterpreterFix(detectedSystemWideSdk, existingSdks, false, module)
}
}
}
@@ -125,23 +133,22 @@ private fun getSuitableSdkFix(name: String?, module: Module): LocalQuickFix? {
if (PyCondaSdkCustomizer.instance.suggestSharedCondaEnvironments) {
val sharedCondaEnv = mostPreferred(filterSharedCondaEnvs(module, existingSdks))
if (sharedCondaEnv != null) return UseExistingInterpreterFix(sharedCondaEnv, module)
if (sharedCondaEnv != null) return@withContext UseExistingInterpreterFix(sharedCondaEnv, module)
}
// TODO: We should use SystemPythonService here as well, postponing as it's quite unlikely we get here (although we can)
val systemWideSdk = mostPreferred(filterSystemWideSdks(existingSdks))
if (systemWideSdk != null) return UseExistingInterpreterFix(systemWideSdk, module)
if (systemWideSdk != null) return@withContext UseExistingInterpreterFix(systemWideSdk, module)
val configurator = PyCondaSdkCustomizer.instance.fallbackConfigurator
if (configurator != null) {
val fallbackCreateSdkInfo = PyCondaSdkCustomizer.checkEnvironmentAndPrepareSdkCreatorBlocking(configurator, module)
if (fallbackCreateSdkInfo != null) return UseProvidedInterpreterFix(module, fallbackCreateSdkInfo)
val fallbackCreateSdkInfo = PyCondaSdkCustomizer.instance.fallbackConfigurator?.let { configurator ->
configurator.checkEnvironmentAndPrepareSdkCreator(module)?.let { CreateSdkInfoWithTool(it, configurator.toolId) }
}
if (fallbackCreateSdkInfo != null) return@withContext UseProvidedInterpreterFix(module, fallbackCreateSdkInfo)
val detectedSystemWideSdk = detectSystemWideSdks(module, existingSdks).firstOrNull()
if (detectedSystemWideSdk != null) return UseDetectedInterpreterFix(detectedSystemWideSdk, existingSdks, false, module)
if (detectedSystemWideSdk != null) return@withContext UseDetectedInterpreterFix(detectedSystemWideSdk, existingSdks, false, module)
return null
null
}
private fun getExistingSdks(): List<Sdk> {
@@ -155,7 +162,7 @@ private fun detectAssociatedViaRootNameEnv(
): PyDetectedSdk? = findAssociatedViaRootNameEnv(associatedName, detectVirtualEnvs(module, existingSdks, context))
private fun detectSystemWideSdk(version: String, module: Module, existingSdks: List<Sdk>, context: UserDataHolderBase): PyDetectedSdk? {
val parsedVersion = LanguageLevel.fromPythonVersion(version)!!
val parsedVersion = LanguageLevel.fromPythonVersion(version)
return if (parsedVersion.toString() == version) {
detectSystemWideSdks(module, existingSdks, context).firstOrNull { it.guessedLanguageLevel == parsedVersion }
@@ -176,7 +183,7 @@ private fun isFileIgnored(pyFile: PyFile): Boolean =
PyInspectionExtension.EP_NAME.extensionList.any { it.ignoreInterpreterWarnings(pyFile) }
class ConfigureInterpreterFix : LocalQuickFix {
private class ConfigureInterpreterFix : LocalQuickFix {
@IntentionFamilyName
override fun getFamilyName(): String = PyPsiBundle.message("INSP.interpreter.configure.python.interpreter")
@@ -39,7 +39,6 @@ import com.jetbrains.python.errorProcessing.emit
import com.jetbrains.python.isCondaVirtualEnv
import com.jetbrains.python.isVirtualEnv
import com.jetbrains.python.packaging.ui.PyPackageManagementService
import com.jetbrains.python.packaging.utils.PyPackageCoroutine
import com.jetbrains.python.psi.LanguageLevel
import com.jetbrains.python.remote.PyRemoteSdkAdditionalData
import com.jetbrains.python.run.PythonInterpreterTargetEnvironmentFactory
@@ -298,7 +297,8 @@ val Sdk.associatedModuleNioPath: Path?
get() =
try {
associatedModulePath?.let { Path(it) }
}catch (e: InvalidPathException) {
}
catch (e: InvalidPathException) {
if (getUserData(SDK_ERROR_REPORTED) != true) {
LOGGER.warn("Can't convert ${associatedModulePath} to path", e)
putUserData(SDK_ERROR_REPORTED, true)
@@ -325,17 +325,6 @@ fun PyDetectedSdk.setup(existingSdks: List<Sdk>): Sdk? {
return SdkConfigurationUtil.setupSdk(existingSdks.toTypedArray(), homeDir, PythonSdkType.getInstance(), null, null)
}
// For Java only
internal fun PyDetectedSdk.setupSdkLaunch(
module: Module,
existingSdks: List<Sdk>,
doAssociate: Boolean,
) {
PyPackageCoroutine.launch(project = module.project) {
setupSdk(module, existingSdks, doAssociate)
}
}
@Internal
suspend fun PyDetectedSdk.setupSdk(
module: Module,
@@ -464,7 +453,7 @@ fun getInnerVirtualEnvRoot(sdk: Sdk): VirtualFile? {
}
internal suspend fun suggestAssociatedSdkName(sdkHome: String, associatedPath: String?): String? = withContext(Dispatchers.IO) {
// please don't forget to update com.jetbrains.python.inspections.interpreter.PyInterpreterInspection.Visitor#getSuitableSdkFix
// please don't forget to update com.jetbrains.python.inspections.interpreter.PyInterpreterInspection#getSuitableSdkFix
// after changing this method
val baseSdkName = PythonSdkType.suggestBaseSdkName(sdkHome) ?: return@withContext null