From fc0731b0efbd8afe298b05ad984defed346859bb Mon Sep 17 00:00:00 2001 From: Alexey Katsman Date: Wed, 18 Feb 2026 09:19:28 +0100 Subject: [PATCH] PY-87227 Suggest a tool installation if it is not already installed Support a mechanism for recommending a tool installation if we're confident it should be used for SDK configuration. For now, it only supports uv (with uv.lock check). It also fixes PY-87744, since we dropped all usages of PyDetectedSdk in our SDK configurators. GitOrigin-RevId: f99a1089025079313db9aae09e3cfc0205e7e37f --- .../intellij.python.community.impl.xml | 8 +- .../messages/PyBundle.properties | 5 +- .../resources/messages/PyPsiBundle.properties | 1 + .../src/impl/ModulesSdkConfigurator.kt | 5 +- .../resources/messages/PySdkBundle.properties | 1 + .../python/sdk/configuration/CreateSdkInfo.kt | 32 +++- python/python-uv/common/src/uvId.kt | 2 + .../PyAsyncFileInspectionRunner.kt | 36 ++++- .../interpreter/PyInterpreterInspection.kt | 27 ++++ .../configuration/PyPoetrySdkConfiguration.kt | 139 ++++++++++-------- .../sdk/add/v2/CustomNewEnvironmentCreator.kt | 4 +- .../sdk/add/v2/PythonAddCustomInterpreter.kt | 2 +- .../PoetryExistingEnvironmentSelector.kt | 13 +- .../jetbrains/python/sdk/add/v2/uiUtils.kt | 2 +- .../PyProjectSdkConfiguration.kt | 45 +++++- .../sdk/poetry/PoetryCommandExecutor.kt | 25 +--- .../configuration/PyUvBaseSdkConfiguration.kt | 23 +++ .../sdk/configuration/PyUvSdkConfiguration.kt | 107 +++----------- .../python/uv/sdk/configuration/uvVenv.kt | 82 +++++++++++ python/src/com/jetbrains/python/uv/uvLock.kt | 11 ++ .../configuration/PyVenvSdkConfiguration.kt | 26 ++-- .../PyAsyncFileInspectionRunnerTest.kt | 10 +- 22 files changed, 404 insertions(+), 202 deletions(-) create mode 100644 python/src/com/jetbrains/python/uv/sdk/configuration/PyUvBaseSdkConfiguration.kt create mode 100644 python/src/com/jetbrains/python/uv/sdk/configuration/uvVenv.kt create mode 100644 python/src/com/jetbrains/python/uv/uvLock.kt diff --git a/python/pluginResources/intellij.python.community.impl.xml b/python/pluginResources/intellij.python.community.impl.xml index bba0ba4142a3..9aee1c730420 100644 --- a/python/pluginResources/intellij.python.community.impl.xml +++ b/python/pluginResources/intellij.python.community.impl.xml @@ -974,12 +974,14 @@ id="environmentYml"/> + "/> + id="poetry"/> - + diff --git a/python/pluginResources/messages/PyBundle.properties b/python/pluginResources/messages/PyBundle.properties index ee9313ea7d56..8e24045d31b7 100644 --- a/python/pluginResources/messages/PyBundle.properties +++ b/python/pluginResources/messages/PyBundle.properties @@ -569,7 +569,8 @@ sdk.create.not.executable.empty.error=Specify path to executable sdk.create.not.executable.does.not.exist.error=Executable does not exist sdk.create.executable.directory.error=Path cannot be a directory sdk.create.tooltip.browse=Browse\u2026 -sdk.create.custom.venv.install.fix.title=Install {0} {1} +sdk.create.custom.venv.install.fix.title=Install {0} +sdk.create.custom.venv.install.fix.title.using.pip=Install {0} using pip sdk.create.custom.venv.run.error.message=Error Running {0} sdk.create.custom.venv.progress.title.detect.executable=Detect executable sdk.create.custom.existing.env.title=Environment: @@ -1802,3 +1803,5 @@ sdk.cannot.find.venv.for.module=Can't find venv for the module sdk.set.up.uv.environment=Set up a uv {0} environment sdk.cannot.find.uv.executable=Cannot find uv executable + +sdk.cannot.find.python=No Pythons were found on the system diff --git a/python/python-psi-impl/resources/messages/PyPsiBundle.properties b/python/python-psi-impl/resources/messages/PyPsiBundle.properties index c3569a883fee..1004708de7e2 100644 --- a/python/python-psi-impl/resources/messages/PyPsiBundle.properties +++ b/python/python-psi-impl/resources/messages/PyPsiBundle.properties @@ -544,6 +544,7 @@ INSP.interpreter.interpreter.settings=Interpreter settings INSP.interpreter.use.interpreter=Use {0} INSP.interpreter.use.suggested.interpreter=Use suggested interpreter INSP.method.parameters.metaclass.method.first.argument.name=Metaclass method first argument name +INSP.interpreter.install.suggested.tool=Install suggested tool INSP.package.requirements.requirements.have.been.ignored=Requirements have been ignored INSP.package.requirements.requirement.has.been.ignored=''{0}'' has been ignored diff --git a/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt b/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt index d18edb2435f4..05b1b349f47d 100644 --- a/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt +++ b/python/python-sdk-configurator/backend/src/impl/ModulesSdkConfigurator.kt @@ -70,7 +70,7 @@ internal class ModulesSdkConfigurator private constructor( is ModuleCreateInfo.CreateSdkInfoWrapper -> { val version = when (val r = createInfo.createSdkInfo) { is CreateSdkInfo.ExistingEnv -> r.pythonInfo.languageLevel.toPythonVersion() - is CreateSdkInfo.WillCreateEnv -> null + is CreateSdkInfo.WillCreateEnv, is CreateSdkInfo.WillInstallTool -> null } ModuleDTO(moduleName, path = createInfo.moduleDir?.let { pathShorter.toString(it) }, @@ -224,9 +224,10 @@ private suspend fun configureSdkForModuleAutomatically(module: Module, createEnv info.createAndSetToModule(module) } else { - logger.trace { "${module.name} can't be configured automatically: no venv for ${info.intentionName}" } + logger.trace { "${module.name} can't be configured automatically: no venv for ${moduleInfo.toolId}" } } } + is CreateSdkInfo.WillInstallTool -> logger.trace { "${module.name} can't be configured automatically: no tool installed - ${moduleInfo.toolId}" } } } is ModuleCreateInfo.SameAs -> { diff --git a/python/python-sdk/resources/messages/PySdkBundle.properties b/python/python-sdk/resources/messages/PySdkBundle.properties index 4664497e80a6..47b7d86a665c 100644 --- a/python/python-sdk/resources/messages/PySdkBundle.properties +++ b/python/python-sdk/resources/messages/PySdkBundle.properties @@ -40,6 +40,7 @@ python.sdk.downloading.progress.details=Downloading ({0}) {1} python.sdk.running.progress.text=Running {0}\u2026 python.sdk.running.one.minute.progress.details=About 1 minute left python.sdk.running.sudo.prompt=Enter your password to install {0} +python.sdk.cannot.create.tool.should.be.installed=Cannot create SDK without a proper tool installed # PLEASE add only the keys that are supposed to be re-used by plugins, # PLEASE keep keys below grouped by the topic, diff --git a/python/python-sdk/src/com/jetbrains/python/sdk/configuration/CreateSdkInfo.kt b/python/python-sdk/src/com/jetbrains/python/sdk/configuration/CreateSdkInfo.kt index a206941658ec..bdb97a4e85c2 100644 --- a/python/python-sdk/src/com/jetbrains/python/sdk/configuration/CreateSdkInfo.kt +++ b/python/python-sdk/src/com/jetbrains/python/sdk/configuration/CreateSdkInfo.kt @@ -7,8 +7,10 @@ import com.intellij.openapi.util.NlsSafe import com.jetbrains.python.PythonInfo import com.jetbrains.python.TraceContext import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.sdk.impl.PySdkBundle import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus +import java.nio.file.Path typealias CheckToml = Boolean typealias EnvExists = Boolean @@ -40,12 +42,10 @@ sealed class CreateSdkInfo(private val sdkCreator: SdkCreator) : getSdkCreator(moduleName).createSdk() /** - * We want to preserve the initial order, but at the same time existing environment should have a higher priority by default + * We want to preserve the initial order, but at the same time we'd like to have a sort order depending on the type of CreateSdkInfo */ override fun compareTo(other: CreateSdkInfo): Int { - val thisExists = if (this is ExistingEnv) 0 else 1 - val otherExists = if (other is ExistingEnv) 0 else 1 - return thisExists.compareTo(otherExists) + return sortOrder.compareTo(other.sortOrder) } class ExistingEnv internal constructor( @@ -54,15 +54,38 @@ sealed class CreateSdkInfo(private val sdkCreator: SdkCreator) : sdkCreator: SdkCreator, ) : CreateSdkInfo(sdkCreator) + class WillInstallTool internal constructor( + val toolToInstall: String, + val pathPersister: (Path) -> Unit, + override val intentionName: @IntentionName String, + ) : CreateSdkInfo( + { + /** + * This specific CreateSdkInfo is only supposed to be used for proposing tool installation, it never should be used for SDK creation. + */ + PyResult.localizedError(PySdkBundle.message("python.sdk.cannot.create.tool.should.be.installed")) + } + ) + class WillCreateEnv internal constructor( override val intentionName: String, sdkCreator: SdkCreator, ) : CreateSdkInfo(sdkCreator) + + private val sortOrder: Int + get() = when (this) { + is ExistingEnv -> 0 + is WillInstallTool -> 1 + is WillCreateEnv -> 2 + } } @ApiStatus.Internal sealed interface EnvCheckerResult { data class EnvFound(val pythonInfo: PythonInfo, val intentionName: @IntentionName String) : EnvCheckerResult + data class SuggestToolInstallation( + val toolToInstall: String, val pathPersister: (Path) -> Unit, val intentionName: @IntentionName String, + ) : EnvCheckerResult data class EnvNotFound(val intentionName: @IntentionName String) : EnvCheckerResult object CannotConfigure : EnvCheckerResult } @@ -79,6 +102,7 @@ suspend fun prepareSdkCreator( sdkCreator(true) ) is EnvCheckerResult.EnvNotFound -> CreateSdkInfo.WillCreateEnv(res.intentionName, sdkCreator(false)) + is EnvCheckerResult.SuggestToolInstallation -> CreateSdkInfo.WillInstallTool(res.toolToInstall, res.pathPersister, res.intentionName) is EnvCheckerResult.CannotConfigure -> null } } diff --git a/python/python-uv/common/src/uvId.kt b/python/python-uv/common/src/uvId.kt index 85e9f9509013..341b700c4e4c 100644 --- a/python/python-uv/common/src/uvId.kt +++ b/python/python-uv/common/src/uvId.kt @@ -8,6 +8,8 @@ import org.jetbrains.annotations.ApiStatus @ApiStatus.Internal val UV_TOOL_ID: ToolId = ToolId("uv") +@ApiStatus.Internal +val UV_BASE_TOOL_ID: ToolId = ToolId("uvBase") // TODO: Move this symbol to backend as soon as all usages are moved to backend @ApiStatus.Internal diff --git a/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt b/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt index f280c22aaa55..f8ce785f763e 100644 --- a/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt +++ b/python/src/com/jetbrains/python/inspections/PyAsyncFileInspectionRunner.kt @@ -4,15 +4,23 @@ 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.codeInsight.intention.CustomizableIntentionAction +import com.intellij.codeInsight.intention.FileModifier +import com.intellij.codeInsight.intention.preview.IntentionPreviewInfo import com.intellij.codeInspection.LocalQuickFix +import com.intellij.codeInspection.ProblemDescriptor +import com.intellij.codeInspection.util.IntentionName import com.intellij.openapi.application.edtWriteAction import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceIfCreated import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.module.Module +import com.intellij.openapi.project.Project import com.intellij.openapi.util.NlsContexts import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile import com.jetbrains.python.psi.PyFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -62,7 +70,7 @@ class PyAsyncFileInspectionRunner( if (!shouldCache) { cache.invalidate(module) } - return fixes + return fixes.map { InspectionRunnerLocalQuickFix(it) { cache.invalidate(module) } } } if (!cached) { @@ -91,5 +99,31 @@ data class InspectionRunnerResult( val shouldCache: Boolean, ) +private class InspectionRunnerLocalQuickFix( + private val fix: LocalQuickFix, + private val cacheEvictor: () -> Unit +) : LocalQuickFix by fix { + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + fix.applyFix(project, descriptor) + cacheEvictor() + } + + /** + * We have to override the following methods manually as the delegate does not override default methods in Java interfaces + */ + override fun generatePreview(project: Project, previewDescriptor: ProblemDescriptor): IntentionPreviewInfo = + fix.generatePreview(project, previewDescriptor) + + override fun getRangesToHighlight(project: Project?, descriptor: ProblemDescriptor?): List = + fix.getRangesToHighlight(project, descriptor) + + override fun getName(): @IntentionName String = fix.name + override fun startInWriteAction(): Boolean = fix.startInWriteAction() + override fun getElementToMakeWritable(currentFile: PsiFile): PsiElement? = fix.getElementToMakeWritable(currentFile) + override fun getFileModifierForPreview(target: PsiFile): FileModifier? = fix.getFileModifierForPreview(target) + override fun availableInBatchMode(): Boolean = fix.availableInBatchMode() +} + @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 fb3457f6dac4..c371276fa823 100644 --- a/python/src/com/jetbrains/python/inspections/interpreter/PyInterpreterInspection.kt +++ b/python/src/com/jetbrains/python/inspections/interpreter/PyInterpreterInspection.kt @@ -17,6 +17,7 @@ import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.Sdk import com.intellij.psi.PsiElementVisitor +import com.intellij.python.common.tools.ToolId import com.intellij.python.pyproject.model.api.ModuleCreateInfo import com.intellij.python.pyproject.model.api.getModuleInfo import com.jetbrains.python.PyPsiBundle @@ -143,6 +144,28 @@ private class UseProvidedInterpreterFix(private val myModule: Module, private va } } +private class SuggestToolInstallationFix( + private val myModule: Module, + private val myCreateSdkInfo: CreateSdkInfo.WillInstallTool, + private val myTool: ToolId, +) : LocalQuickFix { + @IntentionFamilyName + override fun getFamilyName(): String = PyPsiBundle.message("INSP.interpreter.install.suggested.tool") + + @IntentionName + override fun getName(): String = myCreateSdkInfo.intentionName + + override fun applyFix(project: Project, descriptor: ProblemDescriptor) { + PyProjectSdkConfiguration.installToolForInspection(descriptor.psiElement.containingFile, myModule, myCreateSdkInfo, myTool) + PyUiUtil.clearFileLevelInspectionResults(descriptor.psiElement.containingFile) + } + + override fun generatePreview(project: Project, previewDescriptor: ProblemDescriptor): IntentionPreviewInfo { + // The quick fix doesn't change the code and is suggested on a file level + return IntentionPreviewInfo.EMPTY + } +} + private suspend fun Module.getQuickFixBySdkSuggestion(i: ModuleCreateInfo?): FindQuickFixResult = when (i) { is ModuleCreateInfo.CreateSdkInfoWrapper -> { when (val createSdkInfo = i.createSdkInfo) { @@ -168,6 +191,10 @@ private suspend fun Module.getQuickFixBySdkSuggestion(i: ModuleCreateInfo?): Fin val tool = CreateSdkInfoWithTool(createSdkInfo, i.toolId) FindQuickFixResult.ShowUserFix(UseProvidedInterpreterFix(this, tool)) } + is CreateSdkInfo.WillInstallTool -> { + logger.trace { "$this: Tool installation will be suggested to the user" } + FindQuickFixResult.ShowUserFix(SuggestToolInstallationFix(this, createSdkInfo, i.toolId)) + } } } is ModuleCreateInfo.SameAs -> { diff --git a/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt b/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt index b1b44321dc7e..d380724b3269 100644 --- a/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt +++ b/python/src/com/jetbrains/python/poetry/sdk/configuration/PyPoetrySdkConfiguration.kt @@ -1,6 +1,7 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.poetry.sdk.configuration +import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.application.EDT import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.module.Module @@ -12,6 +13,7 @@ import com.intellij.platform.ide.progress.withBackgroundProgress import com.intellij.platform.util.progress.reportRawProgress import com.intellij.python.common.tools.ToolId import com.intellij.python.community.impl.poetry.common.POETRY_TOOL_ID +import com.intellij.python.community.impl.poetry.common.poetryPath import com.intellij.python.pyproject.PyProjectToml import com.jetbrains.python.PyBundle import com.jetbrains.python.PythonBinary @@ -50,80 +52,97 @@ internal class PyPoetrySdkConfiguration : PyProjectTomlConfigurationExtension { override val toolId: ToolId = POETRY_TOOL_ID override suspend fun checkEnvironmentAndPrepareSdkCreator(module: Module, venvsInModule: List): CreateSdkInfo? = - prepareSdkCreator( - { checkManageableEnv(module, true) }, - ) { { createPoetry(module) } } + prepareSdkCreator( + { checkManageableEnv(module, true) }, + ) { { createPoetry(module) } } override suspend fun createSdkWithoutPyProjectTomlChecks(module: Module, venvsInModule: List): CreateSdkInfo? = - prepareSdkCreator( - { checkManageableEnv(module, false) }, - ) { { createPoetry(module) } } + prepareSdkCreator( + { checkManageableEnv(module, false) }, + ) { { createPoetry(module) } } override fun asPyProjectTomlSdkConfigurationExtension(): PyProjectTomlConfigurationExtension = this private suspend fun checkManageableEnv( - module: Module, checkToml: CheckToml, + module: Module, checkToml: CheckToml, ): EnvCheckerResult = reportRawProgress { - it.text(PyBundle.message("python.sdk.validating.environment")) + it.text(PyBundle.message("python.sdk.validating.environment")) + val poetryLockExists = findPoetryLock(module) != null - val isPoetryProject = if (checkToml) { - withContext(Dispatchers.IO) { - PyProjectToml.Companion.findFile(module) - ?.let { toml -> getPyProjectTomlForPoetry(toml) } != null || findPoetryLock(module) != null - } - } else true + val isPoetryProject = if (checkToml) { + withContext(Dispatchers.IO) { + PyProjectToml.findFile(module) + ?.let { toml -> getPyProjectTomlForPoetry(toml) } != null || poetryLockExists + } + } + else true - val canManage = isPoetryProject && getPoetryExecutable() != null - val intentionName = PyBundle.message("sdk.set.up.poetry.environment") - val envNotFound = EnvCheckerResult.EnvNotFound(intentionName) + val canManage = isPoetryProject && getPoetryExecutable() != null + val intentionName = PyBundle.message("sdk.set.up.poetry.environment") + val envNotFound = EnvCheckerResult.EnvNotFound(intentionName) - if (canManage) { - val basePath = module.baseDir?.path?.toNioPathOrNull() - runPoetry(basePath, "check", "--lock").getOr { return@reportRawProgress envNotFound } - val envPath = runPoetry(basePath, "env", "info", "-p") - .mapSuccess { it.toNioPathOrNull() } - .getOr { return@reportRawProgress envNotFound } - envPath?.resolvePythonBinary()?.findEnvOrNull(intentionName) ?: envNotFound - } else EnvCheckerResult.CannotConfigure + if (canManage) { + val basePath = module.baseDir?.path?.toNioPathOrNull() + runPoetry(basePath, "check", "--lock").getOr { return@reportRawProgress envNotFound } + val envPath = runPoetry(basePath, "env", "info", "-p") + .mapSuccess { it.toNioPathOrNull() } + .getOr { return@reportRawProgress envNotFound } + envPath?.resolvePythonBinary()?.findEnvOrNull(intentionName) ?: envNotFound + } + /** + * We're confident that it's a poetry project in two cases: + * - File poetry.lock exists + * - We checked pyproject.toml and there's a specific mention of poetry tool + */ + else if (poetryLockExists || (isPoetryProject && checkToml)) { + val pathPersister: (Path) -> Unit = { path -> PropertiesComponent.getInstance().poetryPath = path.toString() } + val toolName = "poetry" + EnvCheckerResult.SuggestToolInstallation( + toolToInstall = toolName, + pathPersister = pathPersister, + intentionName = PyBundle.message("sdk.create.custom.venv.install.fix.title.using.pip", "poetry") + ) + } + else EnvCheckerResult.CannotConfigure } private suspend fun createPoetry(module: Module): PyResult = - withBackgroundProgress(module.project, PyBundle.message("sdk.progress.text.setting.up.poetry.environment")) { - LOGGER.debug("Creating poetry environment") + withBackgroundProgress(module.project, PyBundle.message("sdk.progress.text.setting.up.poetry.environment")) { + LOGGER.debug("Creating poetry environment") - val basePath = module.baseDir?.path?.let { Path.of(it) } - if (basePath == null) { - return@withBackgroundProgress PyResult.localizedError( - PyBundle.message( - "python.sdk.provided.path.is.invalid", - module.baseDir?.path - ) - ) - } - val tomlFile = PyProjectToml.Companion.findFile(module) - val poetry = setupPoetry(basePath, null, true, tomlFile == null).getOr { return@withBackgroundProgress it } - val path = poetry.resolvePythonBinary() - ?: return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", poetry)) - - val file = LocalFileSystem.getInstance().refreshAndFindFileByPath(path.pathString) - ?: return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", path)) - - LOGGER.debug("Setting up associated poetry environment: $path, $basePath") - val sdk = SdkConfigurationUtil.setupSdk( - PythonSdkUtil.getAllSdks().toTypedArray(), - file, - PythonSdkType.getInstance(), - PyPoetrySdkAdditionalData(module.baseDir?.path?.let { Path.of(it) }), - suggestedSdkName(basePath) + val basePath = module.baseDir?.path?.let { Path.of(it) } + if (basePath == null) { + return@withBackgroundProgress PyResult.localizedError( + PyBundle.message( + "python.sdk.provided.path.is.invalid", + module.baseDir?.path ) - - withContext(Dispatchers.EDT) { - LOGGER.debug("Adding associated poetry environment: $path, $basePath") - sdk.setAssociationToModule(module) - SdkConfigurationUtil.addSdk(sdk) - module.project.pySdkService.persistSdk(sdk) - } - - PyResult.success(sdk) + ) } + 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(PySdkBundle.message("cannot.find.executable", "python", poetry)) + + val file = LocalFileSystem.getInstance().refreshAndFindFileByPath(path.pathString) + ?: return@withBackgroundProgress PyResult.localizedError(PySdkBundle.message("cannot.find.executable", "python", path)) + + LOGGER.debug("Setting up associated poetry environment: $path, $basePath") + val sdk = SdkConfigurationUtil.setupSdk( + PythonSdkUtil.getAllSdks().toTypedArray(), + file, + PythonSdkType.getInstance(), + PyPoetrySdkAdditionalData(module.baseDir?.path?.let { Path.of(it) }), + suggestedSdkName(basePath) + ) + + withContext(Dispatchers.EDT) { + LOGGER.debug("Adding associated poetry environment: $path, $basePath") + sdk.setAssociationToModule(module) + SdkConfigurationUtil.addSdk(sdk) + module.project.pySdkService.persistSdk(sdk) + } + + PyResult.success(sdk) + } } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt index 5f5b1a899b87..a66c0b217736 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/CustomNewEnvironmentCreator.kt @@ -113,7 +113,7 @@ internal abstract class CustomNewEnvironmentCreator

( */ @RequiresEdt protected fun createInstallFix(errorSink: ErrorSink): ActionLink { - return ActionLink(message("sdk.create.custom.venv.install.fix.title", name, "via pip")) { + return ActionLink(message("sdk.create.custom.venv.install.fix.title.using.pip", name)) { PythonSdkFlavor.clearExecutablesCache() installExecutable(errorSink) runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.progress.title.detect.executable")) { @@ -153,7 +153,7 @@ internal abstract class CustomNewEnvironmentCreator

( val pythonExecutablePath = installedSdk?.homePath ?: model.state.baseInterpreter.get()?.homePath val pythonExecutable = pythonExecutablePath?.let { model.fileSystem.getBinaryToExec(it) } ?: return - runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.install.fix.title", name, "via pip")) { + runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.install.fix.title.using.pip", name)) { val versionArgs: List = installationVersion?.let { listOf("-v", it) } ?: emptyList() when (val r = installExecutableViaPythonScript(pythonExecutable, "-n", name, *versionArgs.toTypedArray())) { is Result.Success -> { diff --git a/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt b/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt index 330bfd9e5c06..a7263ce07b5b 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/PythonAddCustomInterpreter.kt @@ -207,7 +207,7 @@ internal class PythonAddCustomInterpreter

( private fun selectBestTool(createSdkInfoWithTool: CreateSdkInfoWithTool) { val (manager, configurators) = when (createSdkInfoWithTool.createSdkInfo) { - is CreateSdkInfo.WillCreateEnv -> { + is CreateSdkInfo.WillCreateEnv, is CreateSdkInfo.WillInstallTool -> { selectionMethod.set(PythonInterpreterSelectionMethod.CREATE_NEW) newInterpreterManager to newInterpreterCreators } diff --git a/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt b/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt index 5926bef0d157..e212bc562f8d 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/poetry/PoetryExistingEnvironmentSelector.kt @@ -5,10 +5,11 @@ import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.module.Module import com.intellij.openapi.observable.properties.ObservableProperty import com.intellij.openapi.projectRoots.Sdk +import com.intellij.python.community.execService.ExecService +import com.intellij.python.community.execService.python.validatePythonAndGetInfo import com.intellij.python.community.impl.poetry.common.POETRY_UI_INFO import com.intellij.python.community.impl.poetry.common.poetryPath import com.jetbrains.python.PyBundle -import com.jetbrains.python.PythonInfo import com.jetbrains.python.Result import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.sdk.ModuleOrProject @@ -26,7 +27,6 @@ import com.jetbrains.python.sdk.poetry.createPoetrySdk import com.jetbrains.python.sdk.poetry.detectPoetryEnvs import com.jetbrains.python.sdk.poetry.isPoetry import com.jetbrains.python.statistics.InterpreterType -import com.jetbrains.python.statistics.version import java.nio.file.Path import kotlin.io.path.pathString @@ -56,11 +56,10 @@ internal class PoetryExistingEnvironmentSelector

(model: PythonMu } override suspend fun detectEnvironments(modulePath: Path): List> { - val existingEnvs = detectPoetryEnvs(null, null, modulePath.pathString).mapNotNull { env -> - env.homePath?.let { path -> - model.fileSystem.parsePath(path).successOrNull?.let { fsPath -> - DetectedSelectableInterpreter(fsPath, PythonInfo(env.version), false, POETRY_UI_INFO) - } + val existingEnvs = detectPoetryEnvs(modulePath).mapNotNull { pythonBinary -> + val pythonInfo = ExecService().validatePythonAndGetInfo(pythonBinary).successOrNull ?: return@mapNotNull null + model.fileSystem.parsePath(pythonBinary.pathString).successOrNull?.let { fsPath -> + DetectedSelectableInterpreter(fsPath, pythonInfo, false, POETRY_UI_INFO) } } return existingEnvs diff --git a/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt b/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt index 6ec04434753d..efcdb4395dca 100644 --- a/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt +++ b/python/src/com/jetbrains/python/sdk/add/v2/uiUtils.kt @@ -451,7 +451,7 @@ private fun ExtendableTextComponent.removeLoadingExtension() { internal fun

createInstallCondaFix(model: PythonAddInterpreterModel

): ActionLink? { if ((model.fileSystem as? FileSystem.Eel)?.eelApi != localEel) return null - return ActionLink(message("sdk.create.custom.venv.install.fix.title", "Miniconda", "")) { + return ActionLink(message("sdk.create.custom.venv.install.fix.title", "Miniconda")) { PythonSdkFlavor.clearExecutablesCache() CondaInstallManager.installLatest(null) runWithModalProgressBlocking(ModalTaskOwner.guess(), message("sdk.create.custom.venv.progress.title.detect.executable")) { diff --git a/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt b/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt index a9f418a121d7..2b59c87bee88 100644 --- a/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt +++ b/python/src/com/jetbrains/python/sdk/configuration/PyProjectSdkConfiguration.kt @@ -1,11 +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.sdk.configuration +import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.edtWriteAction +import com.intellij.openapi.components.Service +import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.thisLogger import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project @@ -15,33 +19,67 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.use import com.intellij.openapi.wm.ex.WelcomeScreenProjectProvider import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.psi.PsiFile +import com.intellij.python.common.tools.ToolId +import com.intellij.python.community.services.systemPython.SystemPythonService import com.jetbrains.python.PyBundle import com.jetbrains.python.PythonPluginDisposable +import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.errorProcessing.emit -import com.jetbrains.python.packaging.utils.PyPackageCoroutine import com.jetbrains.python.sdk.PySdkPopupFactory import com.jetbrains.python.sdk.configuration.suppressors.PyInterpreterInspectionSuppressor import com.jetbrains.python.sdk.configuration.suppressors.PyPackageRequirementsInspectionSuppressor import com.jetbrains.python.sdk.configuration.suppressors.TipOfTheDaySuppressor import com.jetbrains.python.sdk.configurePythonSdk import com.jetbrains.python.sdk.impl.PySdkBundle +import com.jetbrains.python.sdk.installExecutableViaPythonScript import com.jetbrains.python.statistics.ConfiguredPythonInterpreterIdsHolder.Companion.SDK_HAS_BEEN_CONFIGURED_AS_THE_PROJECT_INTERPRETER import com.jetbrains.python.util.ShowingMessageErrorSync +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.nio.file.Path object PyProjectSdkConfiguration { fun configureSdkUsingCreateSdkInfo(module: Module, createSdkInfoWithTool: CreateSdkInfoWithTool) { val lifetime = suppressTipAndInspectionsFor(module, createSdkInfoWithTool.toolId.id) val project = module.project - PyPackageCoroutine.launch(project) { + project.service().scope.launch { withBackgroundProgress(project, createSdkInfoWithTool.createSdkInfo.intentionName, false) { lifetime.use { setSdkUsingCreateSdkInfo(module, createSdkInfoWithTool) } } } } + fun installToolForInspection(psiFile: PsiFile, module: Module, createSdkInfo: CreateSdkInfo.WillInstallTool, toolId: ToolId) { + val lifetime = suppressTipAndInspectionsFor(module, toolId.id) + + val project = module.project + project.service().scope.launch { + withBackgroundProgress(project, createSdkInfo.intentionName, false) { + lifetime.use { installToolAndShowErrorIfNeeded(module, createSdkInfo.pathPersister, createSdkInfo.toolToInstall) } + } + + edtWriteAction { + DaemonCodeAnalyzer.getInstance(project).restart(psiFile, "${createSdkInfo.intentionName} finished") + } + } + } + + private suspend fun installToolAndShowErrorIfNeeded(module: Module, pathPersister: (Path) -> Unit, toolToInstall: String) { + performToolInstallation(pathPersister, toolToInstall).errorOrNull?.also { + ShowingMessageErrorSync.emit(it, module.project) + } + } + + private suspend fun performToolInstallation(pathPersister: (Path) -> Unit, toolToInstall: String): PyResult { + val systemPython = SystemPythonService().findSystemPythons().firstOrNull() + ?: return PyResult.localizedError(PyBundle.message("sdk.cannot.find.python")) + return installExecutableViaPythonScript(systemPython.asExecutablePython.binary, "-n", toolToInstall).mapSuccess(pathPersister) + } + suspend fun setSdkUsingCreateSdkInfo( module: Module, createSdkInfoWithTool: CreateSdkInfoWithTool, ): Boolean = withContext(Dispatchers.Default) { @@ -102,3 +140,6 @@ object PyProjectSdkConfiguration { } } } + +@Service(Service.Level.PROJECT) +private class SdkConfigurationService(val scope: CoroutineScope) diff --git a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt index da6408edcdad..7dc8dbf8ca70 100644 --- a/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt +++ b/python/src/com/jetbrains/python/sdk/poetry/PoetryCommandExecutor.kt @@ -1,9 +1,7 @@ // 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.openapi.module.Module import com.intellij.openapi.projectRoots.Sdk -import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.util.registry.Registry import com.intellij.platform.eel.EelApi import com.intellij.platform.eel.provider.asNioPath @@ -21,10 +19,8 @@ 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.ToolCommandExecutor import com.jetbrains.python.sdk.associatedModulePath -import com.jetbrains.python.sdk.baseDir import com.jetbrains.python.sdk.runTool import com.jetbrains.python.venvReader.VirtualEnvReader import io.github.z4kn4fein.semver.Version @@ -33,8 +29,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus.Internal import org.jetbrains.annotations.Nls -import org.jetbrains.annotations.NonNls -import org.jetbrains.annotations.SystemIndependent import java.nio.file.Path import kotlin.io.path.pathString @@ -55,6 +49,8 @@ private val POETRY_TOOL: ToolCommandExecutor = ToolCommandExecutor( poetryPath }) +private val POETRY_EXCLUDE_NON_DIGITS_REGEX = Regex("""\D+$""") + @Internal suspend fun runPoetry(projectPath: Path?, vararg args: String): PyResult = POETRY_TOOL.runTool(projectPath, *args) @@ -123,26 +119,17 @@ suspend fun setupPoetry( return runPoetry(projectPath, "env", "info", "-p").mapSuccess { Path.of(it) } } -internal suspend fun detectPoetryEnvs( - module: Module?, - existingSdkPaths: Set?, - projectPath: @SystemIndependent @NonNls String?, -): List { - val path = module?.baseDir?.path?.let { Path.of(it) } ?: projectPath?.let { Path.of(it) } ?: return emptyList() - return getPoetryEnvs(path).filter { existingSdkPaths?.contains(getPythonExecutable(it)) != false } - .map { PyDetectedSdk(getPythonExecutable(it)) } -} +internal suspend fun detectPoetryEnvs(searchPath: Path): List = getPoetryEnvs(searchPath).mapNotNull { getPythonExecutable(it) } internal suspend fun getPoetryVersion(): String? = runPoetry(null, "--version") .getOrNull() ?.split(' ') ?.lastOrNull() - ?.replace(Regex("""\D+$"""), "") // strip all non-numeric characters after the version + ?.replace(POETRY_EXCLUDE_NON_DIGITS_REGEX, "") // strip all non-numeric characters after the version -@Internal -suspend fun getPythonExecutable(homePath: String): String = withContext(Dispatchers.IO) { - VirtualEnvReader().findPythonInPythonRoot(Path.of(homePath))?.toString() ?: FileUtil.join(homePath, "bin", "python") +private suspend fun getPythonExecutable(homePathString: String): PythonBinary? = withContext(Dispatchers.IO) { + VirtualEnvReader().findPythonInPythonRoot(Path.of(homePathString)) } /** diff --git a/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvBaseSdkConfiguration.kt b/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvBaseSdkConfiguration.kt new file mode 100644 index 000000000000..73e65d634c54 --- /dev/null +++ b/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvBaseSdkConfiguration.kt @@ -0,0 +1,23 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.uv.sdk.configuration + +import com.intellij.openapi.module.Module +import com.intellij.python.common.tools.ToolId +import com.intellij.python.community.impl.uv.common.UV_BASE_TOOL_ID +import com.jetbrains.python.PythonBinary +import com.jetbrains.python.sdk.configuration.CreateSdkInfo +import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension +import com.jetbrains.python.sdk.configuration.PyProjectTomlConfigurationExtension +import com.jetbrains.python.sdk.configuration.prepareSdkCreator + +internal class PyUvBaseSdkConfiguration : PyProjectSdkConfigurationExtension { + + override val toolId: ToolId = UV_BASE_TOOL_ID + + override suspend fun checkEnvironmentAndPrepareSdkCreator(module: Module, venvsInModule: List): CreateSdkInfo? = + prepareSdkCreator( + { checkManageableUvEnvBase(module, venvsInModule) } + ) { envExists -> { createUvSdk(module, toolId, venvsInModule, envExists) } } + + override fun asPyProjectTomlSdkConfigurationExtension(): PyProjectTomlConfigurationExtension? = null +} diff --git a/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt b/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt index 3a6a98e49fb4..20ffbb73d683 100644 --- a/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt +++ b/python/src/com/jetbrains/python/uv/sdk/configuration/PyUvSdkConfiguration.kt @@ -1,109 +1,46 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.uv.sdk.configuration -import com.intellij.openapi.application.EDT -import com.intellij.openapi.diagnostic.fileLogger import com.intellij.openapi.module.Module -import com.intellij.openapi.projectRoots.Sdk import com.intellij.python.common.tools.ToolId import com.intellij.python.community.impl.uv.common.UV_TOOL_ID -import com.intellij.python.pyproject.model.api.SuggestedSdk -import com.intellij.python.pyproject.model.api.suggestSdk import com.jetbrains.python.PyBundle import com.jetbrains.python.PythonBinary -import com.jetbrains.python.errorProcessing.PyResult -import com.jetbrains.python.onSuccess -import com.jetbrains.python.sdk.baseDir import com.jetbrains.python.sdk.configuration.CreateSdkInfo import com.jetbrains.python.sdk.configuration.EnvCheckerResult import com.jetbrains.python.sdk.configuration.PyProjectTomlConfigurationExtension -import com.jetbrains.python.sdk.configuration.findEnvOrNull import com.jetbrains.python.sdk.configuration.prepareSdkCreator -import com.jetbrains.python.sdk.persist -import com.jetbrains.python.sdk.pyvenvContains -import com.jetbrains.python.sdk.service.PySdkService.Companion.pySdkService -import com.jetbrains.python.sdk.setAssociationToModule -import com.jetbrains.python.sdk.uv.impl.getUvExecutableLocal -import com.jetbrains.python.sdk.uv.setupExistingEnvAndSdk -import com.jetbrains.python.sdk.uv.setupNewUvSdkAndEnv -import com.jetbrains.python.util.ShowingMessageErrorSync -import com.jetbrains.python.venvReader.tryResolvePath -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.jetbrains.python.sdk.uv.impl.setUvExecutableLocal +import com.jetbrains.python.uv.findUvLock import java.nio.file.Path -private val logger = fileLogger() - internal class PyUvSdkConfiguration : PyProjectTomlConfigurationExtension { override val toolId: ToolId = UV_TOOL_ID override suspend fun checkEnvironmentAndPrepareSdkCreator(module: Module, venvsInModule: List): CreateSdkInfo? = prepareSdkCreator( - { checkManageableEnv(module, venvsInModule) } - ) { envExists -> { createUv(module, venvsInModule, envExists) } } + { checkManageableUvEnvWithUvLock(module, venvsInModule) } + ) { envExists -> { createUvSdk(module, toolId, venvsInModule, envExists) } } override suspend fun createSdkWithoutPyProjectTomlChecks(module: Module, venvsInModule: List): CreateSdkInfo? = - prepareSdkCreator( - { checkManageableEnv(module, venvsInModule) } - ) { envExists -> { createUv(module, venvsInModule, envExists) } } + checkEnvironmentAndPrepareSdkCreator(module, venvsInModule) + + private suspend fun checkManageableUvEnvWithUvLock(module: Module, venvsInModule: List): EnvCheckerResult { + val baseCheckResult = checkManageableUvEnvBase(module, venvsInModule) + return when (baseCheckResult) { + is EnvCheckerResult.EnvFound, is EnvCheckerResult.SuggestToolInstallation -> baseCheckResult + is EnvCheckerResult.EnvNotFound -> if (findUvLock(module) != null) baseCheckResult else EnvCheckerResult.CannotConfigure + is EnvCheckerResult.CannotConfigure -> if (findUvLock(module) != null) { + val pathPersister: (Path) -> Unit = { setUvExecutableLocal(it) } + val toolName = "uv" + EnvCheckerResult.SuggestToolInstallation( + toolToInstall = toolName, + pathPersister = pathPersister, + intentionName = PyBundle.message("sdk.create.custom.venv.install.fix.title.using.pip", toolName) + ) + } else baseCheckResult + } + } override fun asPyProjectTomlSdkConfigurationExtension(): PyProjectTomlConfigurationExtension = this - - /** - * This method checks whether uv environment exists and whether uv can manage the environment using the following logic: - * - If uv is not found on the system, the sdk cannot be configured with uv - * - If pyproject.toml check is required - * - If pyproject.toml file is found, we check whether we can manage this project - * - If there's no pyproject.toml, we assume that we cannot configure the project however, - * if we found existing uv environment, we will use it - * - If pyproject.toml check shouldn't be performed, then we just check whether the environment exists - */ - private suspend fun checkManageableEnv( - module: Module, - venvsInModule: List, - ): EnvCheckerResult { - getUvExecutableLocal() ?: return EnvCheckerResult.CannotConfigure - val intentionName = PyBundle.message("sdk.set.up.uv.environment", module.name) - val envFound = getUvEnv(venvsInModule)?.findEnvOrNull(intentionName) - return envFound ?: EnvCheckerResult.EnvNotFound(intentionName) - } - - private suspend fun getUvEnv(venvsInModule: List): PythonBinary? = venvsInModule.firstOrNull { - it.pyvenvContains("uv = ") - } - - private suspend fun Module.getSdkAssociatedModule() = - when (val r = suggestSdk()) { - // Workspace suggested by uv - is SuggestedSdk.SameAs -> if (r.accordingTo == toolId) r.parentModule else null - null, is SuggestedSdk.PyProjectIndependent -> null - } ?: this - - private suspend fun createUv(module: Module, venvsInModule: List, envExists: Boolean): PyResult { - val uv = getUvExecutableLocal() ?: return PyResult.localizedError(PyBundle.message("sdk.cannot.find.uv.executable")) - val sdkAssociatedModule = module.getSdkAssociatedModule() - val workingDir: Path = tryResolvePath(sdkAssociatedModule.baseDir?.path) - ?: throw IllegalStateException("Can't determine working dir for the module") - - val errorSink = ShowingMessageErrorSync.withProject(sdkAssociatedModule.project) - val sdkSetupResult = if (envExists) { - getUvEnv(venvsInModule)?.let { - setupExistingEnvAndSdk(it, uv, workingDir, false) - } ?: run { - logger.warn("Can't find existing uv environment in project, but it was expected. " + - "Probably it was deleted. New environment will be created") - setupNewUvSdkAndEnv(uv, workingDir, null, errorSink) - } - } - else setupNewUvSdkAndEnv(uv, workingDir, null, errorSink) - - sdkSetupResult.onSuccess { - withContext(Dispatchers.EDT) { - it.persist() - it.setAssociationToModule(sdkAssociatedModule) - sdkAssociatedModule.project.pySdkService.persistSdk(it) - } - } - return sdkSetupResult - } } diff --git a/python/src/com/jetbrains/python/uv/sdk/configuration/uvVenv.kt b/python/src/com/jetbrains/python/uv/sdk/configuration/uvVenv.kt new file mode 100644 index 000000000000..7f3e78f13b8d --- /dev/null +++ b/python/src/com/jetbrains/python/uv/sdk/configuration/uvVenv.kt @@ -0,0 +1,82 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.uv.sdk.configuration + +import com.intellij.openapi.application.EDT +import com.intellij.openapi.diagnostic.fileLogger +import com.intellij.openapi.module.Module +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.python.common.tools.ToolId +import com.intellij.python.pyproject.model.api.SuggestedSdk +import com.intellij.python.pyproject.model.api.suggestSdk +import com.jetbrains.python.PyBundle +import com.jetbrains.python.PythonBinary +import com.jetbrains.python.errorProcessing.PyResult +import com.jetbrains.python.onSuccess +import com.jetbrains.python.sdk.baseDir +import com.jetbrains.python.sdk.configuration.EnvCheckerResult +import com.jetbrains.python.sdk.configuration.findEnvOrNull +import com.jetbrains.python.sdk.persist +import com.jetbrains.python.sdk.pyvenvContains +import com.jetbrains.python.sdk.service.PySdkService.Companion.pySdkService +import com.jetbrains.python.sdk.setAssociationToModule +import com.jetbrains.python.sdk.uv.impl.getUvExecutableLocal +import com.jetbrains.python.sdk.uv.setupExistingEnvAndSdk +import com.jetbrains.python.sdk.uv.setupNewUvSdkAndEnv +import com.jetbrains.python.util.ShowingMessageErrorSync +import com.jetbrains.python.venvReader.tryResolvePath +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path + +private val logger = fileLogger() + +internal suspend fun checkManageableUvEnvBase( + module: Module, + venvsInModule: List, +): EnvCheckerResult { + getUvExecutableLocal() ?: return EnvCheckerResult.CannotConfigure + val intentionName = PyBundle.message("sdk.set.up.uv.environment", module.name) + val envFound = getUvEnv(venvsInModule)?.findEnvOrNull(intentionName) + return envFound ?: EnvCheckerResult.EnvNotFound(intentionName) +} + +internal suspend fun createUvSdk(module: Module, toolId: ToolId, venvsInModule: List, envExists: Boolean): PyResult { + val uv = getUvExecutableLocal() ?: return PyResult.localizedError(PyBundle.message("sdk.cannot.find.uv.executable")) + val sdkAssociatedModule = module.getSdkAssociatedModule(toolId) + val workingDir: Path? = tryResolvePath(sdkAssociatedModule.baseDir?.path) + if (workingDir == null) { + throw IllegalStateException("Can't determine working dir for the module") + } + + val errorSink = ShowingMessageErrorSync.withProject(sdkAssociatedModule.project) + val sdkSetupResult = if (envExists) { + getUvEnv(venvsInModule)?.let { + setupExistingEnvAndSdk(it, uv, workingDir, false) + } ?: run { + logger.warn("Can't find existing uv environment in project, but it was expected. " + + "Probably it was deleted. New environment will be created") + setupNewUvSdkAndEnv(uv, workingDir, null, errorSink) + } + } + else setupNewUvSdkAndEnv(uv, workingDir, null, errorSink) + + sdkSetupResult.onSuccess { + withContext(Dispatchers.EDT) { + it.persist() + it.setAssociationToModule(sdkAssociatedModule) + sdkAssociatedModule.project.pySdkService.persistSdk(it) + } + } + return sdkSetupResult +} + +private suspend fun getUvEnv(venvsInModule: List): PythonBinary? = venvsInModule.firstOrNull { + it.pyvenvContains("uv = ") +} + +private suspend fun Module.getSdkAssociatedModule(toolId: ToolId) = + when (val r = suggestSdk()) { + // Workspace suggested by uv + is SuggestedSdk.SameAs -> if (r.accordingTo == toolId) r.parentModule else null + null, is SuggestedSdk.PyProjectIndependent -> null + } ?: this diff --git a/python/src/com/jetbrains/python/uv/uvLock.kt b/python/src/com/jetbrains/python/uv/uvLock.kt new file mode 100644 index 000000000000..22b5b66825ce --- /dev/null +++ b/python/src/com/jetbrains/python/uv/uvLock.kt @@ -0,0 +1,11 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.jetbrains.python.uv + +import com.intellij.openapi.module.Module +import com.intellij.util.FileName +import com.jetbrains.python.sdk.findAmongRoots +import java.nio.file.Path + +val UV_LOCK: FileName = FileName("uv.lock") + +suspend fun findUvLock(module: Module): Path? = module.findAmongRoots(UV_LOCK) diff --git a/python/src/com/jetbrains/python/venv/sdk/configuration/PyVenvSdkConfiguration.kt b/python/src/com/jetbrains/python/venv/sdk/configuration/PyVenvSdkConfiguration.kt index 4bbe01c680f1..7d9cd8c3884c 100644 --- a/python/src/com/jetbrains/python/venv/sdk/configuration/PyVenvSdkConfiguration.kt +++ b/python/src/com/jetbrains/python/venv/sdk/configuration/PyVenvSdkConfiguration.kt @@ -1,8 +1,10 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.jetbrains.python.venv.sdk.configuration +import com.intellij.openapi.application.EDT import com.intellij.openapi.module.Module import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil import com.intellij.openapi.vfs.refreshAndFindVirtualFile import com.intellij.platform.ide.progress.withBackgroundProgress import com.intellij.python.common.tools.ToolId @@ -12,7 +14,8 @@ import com.jetbrains.python.errorProcessing.MessageError import com.jetbrains.python.errorProcessing.PyResult import com.jetbrains.python.projectCreation.createVenvAndSdk import com.jetbrains.python.sdk.ModuleOrProject -import com.jetbrains.python.sdk.PyDetectedSdk +import com.jetbrains.python.sdk.PythonSdkAdditionalData +import com.jetbrains.python.sdk.PythonSdkType import com.jetbrains.python.sdk.baseDir import com.jetbrains.python.sdk.configuration.CreateSdkInfo import com.jetbrains.python.sdk.configuration.EnvCheckerResult @@ -30,7 +33,8 @@ import com.jetbrains.python.sdk.legacy.PythonSdkUtil import com.jetbrains.python.sdk.persist import com.jetbrains.python.sdk.pyvenvContains import com.jetbrains.python.sdk.service.PySdkService.Companion.pySdkService -import com.jetbrains.python.sdk.setupAssociated +import com.jetbrains.python.sdk.setAssociationToModule +import com.jetbrains.python.sdk.suggestAssociatedSdkName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.io.path.name @@ -73,14 +77,18 @@ internal class PyVenvSdkConfiguration : PyProjectSdkConfigurationExtension { getVirtualEnv(venvsInModule)?.refreshAndFindVirtualFile() } ?: return PyResult.failure(MessageError(PyBundle.message("sdk.cannot.find.venv.for.module"))) - val pyDetectedSdk = PyDetectedSdk(pythonBinary.toString()) - val sdk = pyDetectedSdk.setupAssociated( - PythonSdkUtil.getAllSdks(), - module.baseDir?.path, - true, - PyFlavorAndData(PyFlavorData.Empty, VirtualEnvSdkFlavor.getInstance()) - ).getOr { return it } + val sdk = withContext(Dispatchers.EDT) { + SdkConfigurationUtil.setupSdk( + PythonSdkUtil.getAllSdks().toTypedArray(), + pythonBinary, + PythonSdkType.getInstance(), + PythonSdkAdditionalData(PyFlavorAndData(PyFlavorData.Empty, VirtualEnvSdkFlavor.getInstance())), + suggestAssociatedSdkName(pythonBinary.path, module.baseDir?.path) + ) + } + sdk.persist() + sdk.setAssociationToModule(module) module.project.pySdkService.persistSdk(sdk) return PyResult.success(sdk) diff --git a/python/testSrc/com/jetbrains/python/inspections/PyAsyncFileInspectionRunnerTest.kt b/python/testSrc/com/jetbrains/python/inspections/PyAsyncFileInspectionRunnerTest.kt index d7c7640b34d2..8c794cd35539 100644 --- a/python/testSrc/com/jetbrains/python/inspections/PyAsyncFileInspectionRunnerTest.kt +++ b/python/testSrc/com/jetbrains/python/inspections/PyAsyncFileInspectionRunnerTest.kt @@ -57,7 +57,7 @@ class PyAsyncFileInspectionRunnerTest { barrier.complete(Unit) waitUntilAssertSucceeds(timeout = 5.seconds) { - assertIterableEquals(expectedFixes, runner.runInspection(pyFile, module)) + assertIterableEquals(expectedFixes.map { it.name }, runner.runInspection(pyFile, module)?.map { it.name }) } } @@ -70,7 +70,7 @@ class PyAsyncFileInspectionRunnerTest { } waitUntilAssertSucceeds(timeout = 5.seconds) { - assertIterableEquals(expectedFixes, runner.runInspection(pyFile, module)) + assertIterableEquals(expectedFixes.map { it.name }, runner.runInspection(pyFile, module)?.map { it.name }) } (1..100).map { @@ -81,7 +81,7 @@ class PyAsyncFileInspectionRunnerTest { } }.joinAll() - assertIterableEquals(expectedFixes, runner.runInspection(pyFile, module)) + assertIterableEquals(expectedFixes.map { it.name }, runner.runInspection(pyFile, module)?.map { it.name }) assertEquals(1, callCount) } @@ -99,7 +99,7 @@ class PyAsyncFileInspectionRunnerTest { // Wait for the result of the first run waitUntilAssertSucceeds(timeout = 5.seconds) { - assertIterableEquals(expectedFixes, runner.runInspection(pyFile, module)) + assertIterableEquals(expectedFixes.map { it.name }, runner.runInspection(pyFile, module)?.map { it.name }) assertEquals(1, callCount) } @@ -108,7 +108,7 @@ class PyAsyncFileInspectionRunnerTest { // Wait for the result of the second run waitUntilAssertSucceeds(timeout = 5.seconds) { - assertIterableEquals(expectedFixes, runner.runInspection(pyFile, module)) + assertIterableEquals(expectedFixes.map { it.name }, runner.runInspection(pyFile, module)?.map { it.name }) assertEquals(2, callCount) } }