From ab0b2158ed5fc094611f4491f1a72716fb71f796 Mon Sep 17 00:00:00 2001 From: Alexey Katsman Date: Fri, 21 Nov 2025 18:17:33 +0100 Subject: [PATCH] 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 --- .../resources/messages/PyPsiBundle.properties | 1 + .../PyAsyncFileInspectionRunner.kt | 66 +++++++++++++++++ .../interpreter/PyInterpreterInspection.kt | 71 ++++++++++--------- .../src/com/jetbrains/python/sdk/PySdkExt.kt | 17 +---- 4 files changed, 109 insertions(+), 46 deletions(-) create mode 100644 python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties index 95f85ec8efc9..fc17285d463a 100644 --- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties +++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties @@ -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 diff --git a/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt b/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt new file mode 100644 index 000000000000..96758640a12c --- /dev/null +++ b/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt @@ -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, +) { + private val cache: LoadingCache>> = Caffeine.newBuilder() + .expireAfterWrite(cacheTtl.toJavaDuration()) + .weakKeys() + .build { module -> + val project = module.project + project.service().scope.async { + withBackgroundProgress(module.project, progressTitle) { + cacheLoader(module) + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun runInspection(node: PyFile, module: Module): List? { + 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().scope.launch { + backgroundWriteAction { + DaemonCodeAnalyzer.getInstance(project).restart(node, "$progressTitle finished") + } + } + } + } + + return null + } +} + +@Service(Service.Level.PROJECT) +private class InspectionRunnerService(val scope: CoroutineScope) diff --git a/python/src/com/jetbrains/python/inspections/interpreter/PyInterpreterInspection.kt b/python/src/com/jetbrains/python/inspections/interpreter/PyInterpreterInspection.kt index 4481df2800ee..b88b85b9029c 100644 --- a/python/src/com/jetbrains/python/inspections/interpreter/PyInterpreterInspection.kt +++ b/python/src/com/jetbrains/python/inspections/interpreter/PyInterpreterInspection.kt @@ -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 (?\\d\\.\\d+)\\s*(\\((?.+?)\\))?") -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 { @@ -155,7 +162,7 @@ private fun detectAssociatedViaRootNameEnv( ): PyDetectedSdk? = findAssociatedViaRootNameEnv(associatedName, detectVirtualEnvs(module, existingSdks, context)) private fun detectSystemWideSdk(version: String, module: Module, existingSdks: List, 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") diff --git a/python/src/com/jetbrains/python/sdk/PySdkExt.kt b/python/src/com/jetbrains/python/sdk/PySdkExt.kt index a47c2413ada0..9c9feef1a1bb 100644 --- a/python/src/com/jetbrains/python/sdk/PySdkExt.kt +++ b/python/src/com/jetbrains/python/sdk/PySdkExt.kt @@ -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? { return SdkConfigurationUtil.setupSdk(existingSdks.toTypedArray(), homeDir, PythonSdkType.getInstance(), null, null) } -// For Java only -internal fun PyDetectedSdk.setupSdkLaunch( - module: Module, - existingSdks: List, - 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