[python] PY-79486 (WIP): Use real EPs to create SDKs.

The process is described in `ModulesSdkConfigurator` doc.

GitOrigin-RevId: 1a21824e488a2d799b229d7c8355b60b0b177809
This commit is contained in:
Ilya.Kazakevich
2025-10-24 03:41:35 +02:00
committed by intellij-monorepo-bot
parent a1b92b2591
commit a35535b51a
33 changed files with 362 additions and 229 deletions

View File

@@ -5,6 +5,7 @@ import javax.swing.Icon
/**
* Get icon for certain tool id
* Get icon for certain tool id.
* This icon comes along with class because jewel needs it
*/
fun getIcon(id: ToolId): Icon? = ToolIdToIconMapper.EP.extensionList.firstOrNull { it.id == id }?.icon
fun getIcon(id: ToolId): Pair<Icon, Class<*>>? = ToolIdToIconMapper.EP.extensionList.firstOrNull { it.id == id }?.let { Pair(it.icon, it.clazz) }

View File

@@ -9,7 +9,12 @@ interface ToolIdToIconMapper {
val id: ToolId
val icon: Icon
/**
* Class with icons (jewel requires it)
*/
val clazz: Class<*>
companion object {
internal val EP = ExtensionPointName.create<ToolIdToIconMapper>("com.intellij.python.common.toolToIconMapper")
internal val EP = ExtensionPointName.create<ToolIdToIconMapper>("com.intellij.python.common.toolToIconMapper")
}
}

View File

@@ -25,6 +25,7 @@
</extensionPoints>
<extensions defaultExtensionNs="com.intellij">
<python.common.toolToIconMapper implementation="com.intellij.pycharm.community.ide.impl.configuration.PyReqToolIdToIconMapper"/>
<projectService serviceInterface="com.intellij.psi.search.ProjectScopeBuilder"
serviceImplementation="com.intellij.pycharm.community.ide.impl.PyProjectScopeBuilder"
overrides="true"/>

View File

@@ -0,0 +1,13 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.pycharm.community.ide.impl.configuration
import com.intellij.python.common.tools.ToolId
import com.intellij.python.common.tools.spi.ToolIdToIconMapper
import com.jetbrains.python.icons.PythonIcons
import javax.swing.Icon
internal class PyReqToolIdToIconMapper : ToolIdToIconMapper {
override val id: ToolId = PY_REQ_TOOL_ID
override val icon: Icon = PythonIcons.Python.Virtualenv
override val clazz: Class<*> = PythonIcons::class.java
}

View File

@@ -45,10 +45,12 @@ import kotlin.io.path.Path
private val LOGGER = fileLogger()
internal val PY_REQ_TOOL_ID = ToolId("requirements.txt")
@ApiStatus.Internal
class PyRequirementsTxtOrSetupPySdkConfiguration : PyProjectSdkConfigurationExtension {
override val toolId: ToolId = ToolId("PyRequirements") // This is nonsense, but will be dropped soon
override val toolId: ToolId = PY_REQ_TOOL_ID // This is nonsense, but will be dropped soon
override suspend fun checkEnvironmentAndPrepareSdkCreator(module: Module): CreateSdkInfo? = prepareSdkCreator(
{ checkManageableEnv(module) },

View File

@@ -8,4 +8,6 @@ import javax.swing.Icon
internal class HatchIdMapper : ToolIdToIconMapper {
override val id: ToolId = HATCH_TOOL_ID
override val icon: Icon = PythonHatchIcons.Logo
override val clazz: Class<*> = PythonHatchIcons::class.java
}

View File

@@ -10,4 +10,5 @@ import javax.swing.Icon
internal class PoetryToolIdMapper : ToolIdToIconMapper {
override val id: ToolId = POETRY_TOOL_ID
override val icon: Icon = PythonCommunityImplPoetryCommonIcons.Poetry
override val clazz: Class<*> = PythonCommunityImplPoetryCommonIcons::class.java
}

View File

@@ -2,11 +2,11 @@ package com.intellij.python.pyproject.model.internal
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.platform.workspace.jps.entities.ModuleId
import com.intellij.platform.workspace.storage.*
import com.intellij.platform.workspace.storage.WorkspaceEntity
import com.intellij.platform.workspace.storage.annotations.Parent
import com.intellij.python.common.tools.ToolId
internal interface PyProjectTomlWorkspaceEntity : WorkspaceEntity {
interface PyProjectTomlWorkspaceEntity : WorkspaceEntity {
// [tool, probablyWorkspaceRoot?]. If root is null -> tool didn't implement workspace, just participated in this entry creation
val participatedTools: Map<ToolId, ModuleId?>
@@ -15,6 +15,6 @@ internal interface PyProjectTomlWorkspaceEntity : WorkspaceEntity {
val module: ModuleEntity
}
internal val ModuleEntity.pyProjectTomlEntity: PyProjectTomlWorkspaceEntity?
val ModuleEntity.pyProjectTomlEntity: PyProjectTomlWorkspaceEntity?
by WorkspaceEntity.extension()

View File

@@ -36,6 +36,9 @@ jvm_library(
"//platform/remote-topics/shared:rpc-topics",
"//python/python-sdk-configurator/common",
"//python/common",
"//platform/projectModel-impl",
"//platform/workspace/jps",
"//platform/workspace/storage",
]
)
### auto-generated section `build intellij.python.sdkConfigurator.backend` end

View File

@@ -48,5 +48,8 @@
<orderEntry type="module" module-name="intellij.platform.rpc.topics" />
<orderEntry type="module" module-name="intellij.python.sdkConfigurator.common" />
<orderEntry type="module" module-name="intellij.python.common" />
<orderEntry type="module" module-name="intellij.platform.projectModel.impl" />
<orderEntry type="module" module-name="intellij.platform.workspace.jps" />
<orderEntry type="module" module-name="intellij.platform.workspace.storage" />
</component>
</module>

View File

@@ -16,6 +16,10 @@
<extensions defaultExtensionNs="com.intellij.platform">
<rpc.backend.remoteApiProvider implementation="com.intellij.python.sdkConfigurator.backend.impl.rpcBridge.ApiProvider"/>
</extensions>
<extensions defaultExtensionNs="com.intellij">
<registryKey defaultValue="1" description="How many SDK lookup processes execute in parallel"
key="intellij.python.sdkConfigurator.backend.sdk.parallel"/>
</extensions>
<actions>
<action class="com.intellij.python.sdkConfigurator.backend.impl.platformBridge.ConfigureSDKAction" id="ConfigureSDKAction"/>

View File

@@ -1,3 +1,4 @@
intellij.python.sdk.configuring.module=Configuring SDK for module {0}
action.ConfigureSDKAction.text=Configure SDK Automatically
action.ConfigureSDKAction.description=Configure SDK for all modules in project
action.ConfigureSDKAction.description=Configure SDK for all modules in project
intellij.python.sdk.looking=Looking for pythons

View File

@@ -0,0 +1,183 @@
package com.intellij.python.sdkConfigurator.backend.impl
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.modules
import com.intellij.openapi.roots.ModuleRootManager
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.intellij.openapi.util.Key
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.util.removeUserData
import com.intellij.platform.ide.progress.withBackgroundProgress
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.intellij.python.sdkConfigurator.backend.impl.ModulesSdkConfigurator.Companion.create
import com.intellij.python.sdkConfigurator.backend.impl.ModulesSdkConfigurator.Companion.popModulesSDKConfigurator
import com.intellij.python.sdkConfigurator.common.impl.CreateSdkDTO
import com.intellij.python.sdkConfigurator.common.impl.ModuleName
import com.intellij.python.sdkConfigurator.common.impl.ModulesDTO
import com.jetbrains.python.Result
import com.jetbrains.python.sdk.configuration.CreateSdkInfo
import com.jetbrains.python.sdk.configuration.CreateSdkInfoWithTool
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
import com.jetbrains.python.sdk.getOrCreateAdditionalData
import com.jetbrains.python.sdk.setAssociationToPath
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
/**
* Configures SDK for modules in [project].
*
* 1. Create instance with [create]
* 2. Ask use to choose from [modulesDTO]
* 3. Provide chosen names to [configureSdks]
*
* [create] saves instance in project, so you can get in by [popModulesSDKConfigurator]
*/
internal class ModulesSdkConfigurator private constructor(
private val project: Project,
private val modules: Map<ModuleName, Pair<ModuleCreateInfo, CreateSdkDTO>>,
val modulesDTO: ModulesDTO = ModulesDTO(modules.map { Pair(it.key, it.value.second) }.toMap()),
) {
companion object {
/**
* Create instance and save in [project]
*/
suspend fun create(project: Project): ModulesSdkConfigurator = ModulesSdkConfigurator(project, getModulesWithoutSDKCreateInfo(project)).also {
project.putUserData(key, it)
}
/**
* Get instance from project and **clear it**
*/
fun Project.popModulesSDKConfigurator(): ModulesSdkConfigurator {
val instance = getUserData(key)
?: error("No ${ModulesSdkConfigurator::class.java} found in $this. Did you call ${::create} or did you already called this method?")
removeUserData(key) // Drop prev. usage to prevent leak
return instance
}
private suspend fun getModulesWithoutSDKCreateInfo(project: Project): Map<ModuleName, Pair<ModuleCreateInfo, CreateSdkDTO>> = withBackgroundProgress(project, PySdkConfiguratorBundle.message("intellij.python.sdk.looking")) {
val tools = PyProjectSdkConfigurationExtension.createMap()
val limit = Semaphore(permits = Registry.intValue("intellij.python.sdkConfigurator.backend.sdk.parallel"))
val now = System.currentTimeMillis()
val resultDef = project.modules.filter { ModuleRootManager.getInstance(it).sdk == null }.map { module ->
limit.withPermit {
async {
val moduleInfo = getModuleInfo(module, tools) ?: return@async null
Pair(module, moduleInfo)
}
}
}
val result = resultDef.awaitAll().filterNotNull()
logger.debug { "SDKs calculated in ${System.currentTimeMillis() - now}ms" }
result.associate { (module, createInfoAndDTO) ->
val (createInfo, dto) = createInfoAndDTO
//module.putUserData(modulesKey, createInfo)
Pair(module.name, Pair(createInfo, dto))
}
}
private val logger = fileLogger()
private sealed interface ModuleCreateInfo {
data class CreateSdkInfoWrapper(val createSdkInfo: CreateSdkInfo) : ModuleCreateInfo
data class SameAs(val parentModuleName: ModuleName) : ModuleCreateInfo
}
private suspend fun getModuleInfo(module: Module, configuratorsByTool: Map<ToolId, PyProjectSdkConfigurationExtension>): Pair<ModuleCreateInfo, CreateSdkDTO>? = // Save on module level
when (val r = module.suggestSdk()) {
is SuggestedSdk.PyProjectIndependent -> {
val tools = r.preferTools.map { configuratorsByTool[it]!! }
tools.firstNotNullOfOrNull { tool ->
val createInfo = (tool.asPyProjectTomlSdkConfigurationExtension()?.createSdkWithoutPyProjectTomlChecks(module)
?: tool.checkEnvironmentAndPrepareSdkCreator(module)) ?: return@firstNotNullOfOrNull null
CreateSdkInfoWithTool(createInfo, tool.toolId).asDTO()
}
}
is SuggestedSdk.SameAs -> {
val createInfo = ModuleCreateInfo.SameAs(r.parentModule.name)
Pair(createInfo, createInfo.asDTO())
}
null -> null
} // No tools or not pyproject.toml at all? Use EP as a fallback
?: PyProjectSdkConfigurationExtension.findAllSortedForModule(module).firstOrNull()?.let { CreateSdkInfoWithTool(it.createSdkInfo, it.toolId).asDTO() }
private fun CreateSdkInfoWithTool.asDTO(): Pair<ModuleCreateInfo, CreateSdkDTO.ConfigurableModule> {
val version = when (val r = createSdkInfo) {
is CreateSdkInfo.ExistingEnv -> r.pythonInfo.languageLevel.toPythonVersion()
is CreateSdkInfo.WillCreateEnv -> null
}
return Pair(ModuleCreateInfo.CreateSdkInfoWrapper(createSdkInfo), CreateSdkDTO.ConfigurableModule(version, toolId.id))
}
private fun ModuleCreateInfo.SameAs.asDTO(): CreateSdkDTO.SameAs = CreateSdkDTO.SameAs(parentModuleName)
/**
* Key used to store instance in project by [create] to be used by [popModulesSDKConfigurator]
*/
private val key = Key.create<ModulesSdkConfigurator>("pyModulesSDKConfigurator")
}
/**
* Configures SDK for modules [modulesOnly]
* Errors are logged.
*
*/
suspend fun configureSdks(modulesOnly: Set<ModuleName>) {
withContext(Dispatchers.Default) {
val modulesMap = project.modules.associateBy { it.name }
val modulesWithSameSdk = mutableMapOf<Module, Module>()
for (module in modulesOnly.map { modulesMap[it] ?: error("No module $it, caller broke the contract") }) { // TODO: Run in parallel
withBackgroundProgress(project, PySdkConfiguratorBundle.message("intellij.python.sdk.configuring.module", module.name)) {
val createInfo = (modules[module.name] ?: error("No create info for module $module, caller broke the contract")).first
when (createInfo) {
is ModuleCreateInfo.CreateSdkInfoWrapper -> {
when (val r = createInfo.createSdkInfo.sdkCreator(false)) {
is Result.Failure -> { //TODO: Show SDK creation error?
logger.warn("Failed to create SDK for ${module.name}: ${r.error}")
}
is Result.Success -> {
val sdk = r.result!! // can't be `null` and will be non-null soon
ModuleRootModificationUtil.setModuleSdk(module, sdk)
}
}
}
is ModuleCreateInfo.SameAs -> {
val parent = modulesMap[createInfo.parentModuleName] ?: error("No parent module named ${createInfo.parentModuleName}")
modulesWithSameSdk[module] = parent
}
}
} // Link workspace members with their workspace
val reportedBrokenModules = mutableSetOf<Module>()
for ((module, parentModule) in modulesWithSameSdk) {
val parentSdk = ModuleRootManager.getInstance(parentModule).sdk
if (parentSdk != null) {
ModuleRootModificationUtil.setModuleSdk(module, parentSdk) // This SDK is shared, no need to associate it
// TODO: Support association with multiple modules
if (parentSdk.getOrCreateAdditionalData().associatedModulePath != null) {
parentSdk.setAssociationToPath(null)
}
}
else {
if (parentModule != reportedBrokenModules) {
logger.warn("No sdk for workspace root ${parentModule}, all children will have no SDKs")
}
reportedBrokenModules.add(parentModule)
}
}
}
}
}
}

View File

@@ -3,24 +3,9 @@ package com.intellij.python.sdkConfigurator.backend.impl
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.Service.Level
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.module.Module
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.modules
import com.intellij.openapi.roots.ModuleRootManager
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.platform.rpc.topics.sendToClient
import com.intellij.python.pyproject.model.api.SuggestedSdk
import com.intellij.python.pyproject.model.api.suggestSdk
import com.intellij.python.sdkConfigurator.common.impl.ModuleName
import com.intellij.python.sdkConfigurator.common.impl.ModulesDTO
import com.intellij.python.sdkConfigurator.common.impl.SHOW_SDK_CONFIG_UI_TOPIC
import com.jetbrains.python.Result
import com.jetbrains.python.sdk.configuration.CreateSdkInfo
import com.jetbrains.python.sdk.configuration.PyProjectSdkConfigurationExtension
import com.jetbrains.python.sdk.getOrCreateAdditionalData
import com.jetbrains.python.sdk.setAssociationToPath
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -31,7 +16,7 @@ import kotlinx.coroutines.withContext
private val askUserMutex = Mutex()
/**
* Same as [configureSdkAutomatically] but in a separate coroutine
* Same as [configureSdkAskingUser] but in a separate coroutine
*/
internal fun configureSdkAskingUserBg(project: Project) {
project.service<MyService>().scope.launch(Dispatchers.Default) {
@@ -45,122 +30,15 @@ internal fun configureSdkAskingUserBg(project: Project) {
internal suspend fun configureSdkAskingUser(project: Project) {
withContext(Dispatchers.Default) {
askUserMutex.withLock {
val moduleToSuggestedSdk = getModulesWithoutSDK(project)
if (moduleToSuggestedSdk.modules.isNotEmpty()) {
val moduleToSuggestedSdk = ModulesSdkConfigurator.create(project)
val modulesDTO = moduleToSuggestedSdk.modulesDTO
if (modulesDTO.modules.isNotEmpty()) {
// No need to send empty list
SHOW_SDK_CONFIG_UI_TOPIC.sendToClient(project, moduleToSuggestedSdk)
SHOW_SDK_CONFIG_UI_TOPIC.sendToClient(project, modulesDTO)
}
}
}
}
/**
* Configures SDK for modules without SDK in automatic manner trying to fix as many modules as possible.
* Errors are logged.
*/
internal suspend fun configureSdkAutomatically(project: Project, modulesOnly: Set<ModuleName>? = null) {
withContext(Dispatchers.Default) {
val modules = project.modules
.filter { ModuleRootManager.getInstance(it).sdk == null }
.filter { modulesOnly == null || it.name in modulesOnly }
if (modules.isEmpty()) {
// All modules have SDK
return@withContext
}
val configurators = PyProjectSdkConfigurationExtension.EP_NAME.extensionList
val configuratorsByTool = configurators
.mapNotNull { extension -> extension.asPyProjectTomlSdkConfigurationExtension()?.toolId?.let { Pair(it, extension) } }
.toMap()
assert(configurators.isNotEmpty()) { "PyCharm can't work without any SDK configurator" }
val tomlBasedConfigurators = configuratorsByTool.values
val legacyConfigurators = configurators.filter { it.asPyProjectTomlSdkConfigurationExtension() == null }
val allSortedConfigurators = tomlBasedConfigurators + legacyConfigurators
val modulesWithSameSdk = mutableMapOf<Module, Module>()
for (module in modules) {
// TODO: Run in parallel
withBackgroundProgress(project, PySdkConfiguratorBundle.message("intellij.python.sdk.configuring.module", module.name)) {
when (val r = module.suggestSdk()) {
null -> {
// Not a pyproject.toml: try all configurators
configureSdkForModule(module, allSortedConfigurators, checkForIntention = true)
}
is SuggestedSdk.PyProjectIndependent -> {
val preferredConfigurators = r.preferTools.mapNotNull { configuratorsByTool[it] }
if (!configureSdkForModule(module, preferredConfigurators, checkForIntention = false)) {
// For pyproject.toml based -- use pyproject.toml only configs
configureSdkForModule(module, tomlBasedConfigurators - preferredConfigurators.toSet(), checkForIntention = true)
}
}
is SuggestedSdk.SameAs -> {
modulesWithSameSdk[module] = r.parentModule
}
}
// Link workspace members with their workspace
val reportedBrokenModules = mutableSetOf<Module>()
for ((module, parentModule) in modulesWithSameSdk) {
val parentSdk = ModuleRootManager.getInstance(parentModule).sdk
if (parentSdk != null) {
ModuleRootModificationUtil.setModuleSdk(module, parentSdk)
// This SDK is shared, no need to associate it
// TODO: Support association with multiple modules
if (parentSdk.getOrCreateAdditionalData().associatedModulePath != null) {
parentSdk.setAssociationToPath(null)
}
}
else {
if (parentModule != reportedBrokenModules) {
logger.warn("No sdk for workspace root ${parentModule}, all children will have no SDKs")
}
reportedBrokenModules.add(parentModule)
}
}
}
}
}
}
private suspend fun getModulesWithoutSDK(project: Project): ModulesDTO =
ModulesDTO(project.modules.filter { ModuleRootManager.getInstance(it).sdk == null }.associate { module ->
val parent = when (val r = module.suggestSdk()) {
null, is SuggestedSdk.PyProjectIndependent -> null
is SuggestedSdk.SameAs -> r.parentModule
}
Pair(module.name, parent?.name)
})
private suspend fun configureSdkForModule(module: Module, configurators: List<PyProjectSdkConfigurationExtension>, checkForIntention: Boolean): Boolean {
// TODO: Parallelize call to checkEnvironmentAndPrepareSdkCreator
val createSdkInfos = configurators.mapNotNull {
if (checkForIntention) it.checkEnvironmentAndPrepareSdkCreator(module)
else it.asPyProjectTomlSdkConfigurationExtension()?.createSdkWithoutPyProjectTomlChecks(module)
}.sorted()
for (createSdkInfo in createSdkInfos) {
val created = when (val r = createSdkInfo.sdkCreator(false)) {
is Result.Failure -> {
val msgExtraInfo = when (createSdkInfo) {
is CreateSdkInfo.ExistingEnv -> " using existing environment "
is CreateSdkInfo.WillCreateEnv -> " "
}
logger.warn("can't create SDK${msgExtraInfo}for ${module.name}: ${r.error.message}")
false
}
is Result.Success -> r.result?.also { sdk ->
ModuleRootModificationUtil.setModuleSdk(module, sdk)
} != null
}
if (created) {
return true
}
}
return false
}
private val logger = fileLogger()
@Service(Level.PROJECT)
private class MyService(val scope: CoroutineScope)
private class MyService(val scope: CoroutineScope)

View File

@@ -2,14 +2,14 @@ package com.intellij.python.sdkConfigurator.backend.impl.rpcBridge
import com.intellij.platform.project.ProjectId
import com.intellij.platform.project.findProject
import com.intellij.python.sdkConfigurator.backend.impl.ModulesSdkConfigurator.Companion.popModulesSDKConfigurator
import com.intellij.python.sdkConfigurator.backend.impl.configureSdkAskingUser
import com.intellij.python.sdkConfigurator.backend.impl.configureSdkAutomatically
import com.intellij.python.sdkConfigurator.common.impl.ModuleName
import com.intellij.python.sdkConfigurator.common.impl.SdkConfiguratorBackEndApi
internal object SdkConfiguratorApiImpl : SdkConfiguratorBackEndApi {
override suspend fun configureSdkAutomatically(projectId: ProjectId, onlyModules: Set<ModuleName>) {
configureSdkAutomatically(projectId.findProject(), onlyModules)
override suspend fun configureSdkForModules(projectId: ProjectId, onlyModules: Set<ModuleName>) {
projectId.findProject().popModulesSDKConfigurator().configureSdks(onlyModules)
}
override suspend fun configureAskingUser(projectId: ProjectId) {

View File

@@ -35,6 +35,7 @@ jvm_library(
"//platform/util/coroutines",
"//platform/project/shared:project",
"//platform/remote-topics/shared:rpc-topics",
"//python/common",
]
)
### auto-generated section `build intellij.python.sdkConfigurator.common` end

View File

@@ -47,5 +47,6 @@
<orderEntry type="module" module-name="intellij.platform.util.coroutines" />
<orderEntry type="module" module-name="intellij.platform.project" />
<orderEntry type="module" module-name="intellij.platform.rpc.topics" />
<orderEntry type="module" module-name="intellij.python.common" />
</component>
</module>

View File

@@ -1,5 +1,9 @@
<idea-plugin>
<idea-plugin visibility="internal">
<dependencies>
<module name="intellij.python.common"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<registryKey defaultValue="false" description="Configure SDK for modules which lack thereof in automatic manner" key="intellij.python.sdkConfigurator.auto" restartRequired="true"/>
<registryKey defaultValue="false" description="Configure SDK for modules which lack thereof in automatic manner"
key="intellij.python.sdkConfigurator.auto" restartRequired="true"/>
</extensions>
</idea-plugin>

View File

@@ -6,7 +6,6 @@ import com.intellij.platform.rpc.topics.ProjectRemoteTopic
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
import kotlinx.serialization.Serializable
/**
* Front calls back
@@ -14,29 +13,25 @@ import kotlinx.serialization.Serializable
@Rpc
interface SdkConfiguratorBackEndApi : RemoteApi<Unit> {
/***
* Configure SDK for all modules in [projectId] if their names in [onlyModules] unconditionally
* Configure SDK for all modules in [projectId] if their names in [onlyModules]
*/
suspend fun configureSdkAutomatically(projectId: ProjectId, onlyModules: Set<ModuleName>)
suspend fun configureSdkForModules(projectId: ProjectId, onlyModules: Set<ModuleName>)
/**
* Ask user about modules, then call [configureSdkAutomatically]
* Ask user about modules, then call [configureSdkForModules]
*/
suspend fun configureAskingUser(projectId: ProjectId)
}
typealias ModuleName = String
/**
* Ask user to choose from [ModulesDTO] and then call [SdkConfiguratorBackEndApi.configureSdkAutomatically]
* Ask user to choose from [ModulesDTO] and then call [SdkConfiguratorBackEndApi.configureSdkForModules]
*/
val SHOW_SDK_CONFIG_UI_TOPIC: ProjectRemoteTopic<ModulesDTO> = ProjectRemoteTopic("PySDKConfigurationUITopic", ModulesDTO.serializer())
/**
* Module to parent (workspace) or null if module doesn't have a parent (not a part of workspace)
*/
@Serializable
data class ModulesDTO(val modules: Map<ModuleName, ModuleName?>) {
}
/**
* [SdkConfiguratorBackEndApi] instance

View File

@@ -0,0 +1,30 @@
package com.intellij.python.sdkConfigurator.common.impl
import com.intellij.openapi.util.NlsSafe
import kotlinx.serialization.Serializable
typealias ModuleName = @NlsSafe String
typealias ToolIdDTO = @NlsSafe String // value classes aren't serializable by default
// Serializable DTO that reflects regular object is, unfortunately, recommended approach
@Serializable
sealed interface CreateSdkDTO {
/**
* This module is part of workspace and parent is [parentModuleName]
*/
@Serializable
data class SameAs(val parentModuleName: ModuleName) : CreateSdkDTO
/**
* [createdByTool] can create an SDK for this module (if [existingVersion] is not null, venv is already exists on disk)
*/
@Serializable
data class ConfigurableModule(val existingVersion: @NlsSafe String?, val createdByTool: ToolIdDTO) : CreateSdkDTO
}
/**
* Module and how do we create it
*/
@Serializable
data class ModulesDTO(val modules: Map<ModuleName, CreateSdkDTO>)

View File

@@ -39,6 +39,7 @@ jvm_library(
"//libraries/compose-foundation-desktop",
"//platform/jewel/foundation",
"//libraries/kotlinx/collections-immutable:libraries-kotlinx-collections-immutable",
"//python/common",
],
plugins = ["@lib//:compose-plugin"]
)

View File

@@ -52,5 +52,6 @@
<orderEntry type="module" module-name="intellij.libraries.compose.foundation.desktop" />
<orderEntry type="module" module-name="intellij.platform.jewel.foundation" />
<orderEntry type="module" module-name="intellij.libraries.kotlinx.collections.immutable" />
<orderEntry type="module" module-name="intellij.python.common" />
</component>
</module>

View File

@@ -9,6 +9,7 @@
<module name="intellij.platform.jewel.foundation"/>
<module name="intellij.platform.jewel.ui"/>
<module name="intellij.platform.jewel.ideLafBridge"/>
<module name="intellij.python.common"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<platform.rpc.projectRemoteTopicListener

View File

@@ -2,3 +2,6 @@ python.sdk.configurator.frontend.choose.modules.title=Configure Modules Environm
python.sdk.configurator.frontend.choose.modules.text=Pick the ones you want to use to run code and set up environment for them
python.sdk.configurator.frontend.choose.modules.project.structure=Project Structure
python.sdk.configurator.frontend.choose.modules.environment=Environment
python.sdk.configurator.frontend.choose.modules.workspace.member=Same as {0}
python.sdk.configurator.frontend.choose.modules.workspace.existing=Use existing version {0}
python.sdk.configurator.frontend.choose.modules.new=Create new environment

View File

@@ -2,14 +2,12 @@
package com.intellij.python.sdkConfigurator.frontend;
import com.intellij.DynamicBundle;
import org.jetbrains.annotations.Nls;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.PropertyKey;
import org.jetbrains.annotations.*;
import java.util.function.Supplier;
final class PySdkConfiguratorFrontendBundle extends DynamicBundle {
@ApiStatus.Internal
public final class PySdkConfiguratorFrontendBundle extends DynamicBundle {
public static final @NonNls String BUNDLE = "messages.PySdkConfiguratorFrontendBundle";
public static final PySdkConfiguratorFrontendBundle INSTANCE = new PySdkConfiguratorFrontendBundle();

View File

@@ -6,26 +6,36 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateSet
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.intellij.python.sdkConfigurator.frontend.ModuleInfo
import com.intellij.python.sdkConfigurator.common.impl.CreateSdkDTO
import com.intellij.python.sdkConfigurator.common.impl.ToolIdDTO
import com.intellij.python.sdkConfigurator.frontend.PySdkConfiguratorFrontendBundle
import kotlinx.collections.immutable.ImmutableMap
import org.jetbrains.annotations.Nls
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.CheckboxRow
import org.jetbrains.jewel.ui.component.Icon
import org.jetbrains.jewel.ui.component.Text
import org.jetbrains.jewel.ui.component.VerticallyScrollableContainer
import org.jetbrains.jewel.ui.icon.IconKey
@Composable
internal fun ModuleList(
moduleItems: ImmutableMap<String, ModuleInfo>,
moduleItems: ImmutableMap<String, CreateSdkDTO>,
icons: ImmutableMap<ToolIdDTO, IconKey>,
checked: SnapshotStateSet<String>,
onCheckChange: (String, Boolean) -> Unit,
topLabel: @Nls String,
projectStructureLabel: @Nls String,
environmentLabel: @Nls String,
) {
val newText = remember { PySdkConfiguratorFrontendBundle.message("python.sdk.configurator.frontend.choose.modules.new") }
val ts = JewelTheme.defaultTextStyle
val padding = 2.dp
val longestItemChars = remember { (listOf(projectStructureLabel) + moduleItems.keys).maxBy { it.length } }
@@ -41,7 +51,10 @@ internal fun ModuleList(
VerticallyScrollableContainer {
Column(Modifier, horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(padding)) {
for ((moduleName, moduleInfo) in moduleItems) {
val (parent, pythons) = moduleInfo
val parent = when (moduleInfo) {
is CreateSdkDTO.ConfigurableModule -> null
is CreateSdkDTO.SameAs -> moduleInfo.parentModuleName
}
Row(Modifier.padding(padding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(spaceBetweenCols)) {
val checked = moduleName in checked
val elementToChange = parent ?: moduleName // TODO: move logic out of UI
@@ -54,12 +67,32 @@ internal fun ModuleList(
textStyle = ts,
textModifier = Modifier.width(leftColumnMinSize)
)
if (parent == null) {
PythonsDropDown(pythons)
val text = when (moduleInfo) {
is CreateSdkDTO.ConfigurableModule -> {
val text = moduleInfo.existingVersion?.let { PySdkConfiguratorFrontendBundle.message("python.sdk.configurator.frontend.choose.modules.workspace.existing", it) }
?: newText
val icon = icons[moduleInfo.createdByTool]
if (icon != null) {
Icon(icon, text)
}
text
}
is CreateSdkDTO.SameAs -> {
PySdkConfiguratorFrontendBundle.message("python.sdk.configurator.frontend.choose.modules.workspace.member", moduleInfo.parentModuleName)
}
}
Text(text, Modifier.fillMaxWidth())
}
}
}
}
}
}
@Composable
private fun measureText(text: String, textStyle: TextStyle): Dp {
val textMeasurer = rememberTextMeasurer()
return with(LocalDensity.current) {
textMeasurer.measure(text, textStyle).size.width.toDp()
}
}

View File

@@ -1,40 +0,0 @@
package com.intellij.python.sdkConfigurator.frontend.components
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import org.jetbrains.jewel.foundation.ExperimentalJewelApi
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.ListComboBox
import org.jetbrains.jewel.ui.component.SimpleListItem
import org.jetbrains.jewel.ui.icons.AllIconsKeys.Language.Python
@OptIn(ExperimentalJewelApi::class)
@Composable
internal fun PythonsDropDown(pythons: ImmutableList<String>, grayedOut: Boolean = false, modifier: Modifier = Modifier) {
val ts = JewelTheme.defaultTextStyle
val longestPython = remember { pythons.maxBy { it.length } }
val padding = 100.dp
val width = measureText(longestPython, ts) + padding // Padding
var i by remember { mutableIntStateOf(0) }
ListComboBox(
items = pythons,
selectedIndex = i,
modifier = modifier.widthIn(min = width, max = width + padding),
onSelectedItemChange = { i = it },
itemKeys = { index, _ -> index },
itemContent = { item, isSelected, isActive ->
SimpleListItem(
text = item,
selected = isSelected,
active = isActive,
icon = Python,
colorFilter = if (grayedOut) ColorFilter.tint(Color.Gray) else null,
)
},
)
}

View File

@@ -1,15 +0,0 @@
package com.intellij.python.sdkConfigurator.frontend.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
@Composable
internal fun measureText(text: String, textStyle: TextStyle): Dp {
val textMeasurer = rememberTextMeasurer()
return with(LocalDensity.current) {
textMeasurer.measure(text, textStyle).size.width.toDp()
}
}

View File

@@ -2,30 +2,46 @@ package com.intellij.python.sdkConfigurator.frontend
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.snapshots.SnapshotStateSet
import com.intellij.python.common.tools.ToolId
import com.intellij.python.common.tools.getIcon
import com.intellij.python.sdkConfigurator.common.impl.CreateSdkDTO
import com.intellij.python.sdkConfigurator.common.impl.ModuleName
import com.intellij.python.sdkConfigurator.common.impl.ModulesDTO
import kotlinx.collections.immutable.ImmutableList
import com.intellij.python.sdkConfigurator.common.impl.ToolIdDTO
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
private val fakePythons = arrayOf("Python 3.10", "Python 3.11")
import org.jetbrains.jewel.bridge.icon.fromPlatformIcon
import org.jetbrains.jewel.ui.icon.IconKey
import org.jetbrains.jewel.ui.icon.IntelliJIconKey
/**
* UI should display [checkBoxItems] (either enabled or disabled). On each click call [clicked].
* Result can be taken from [checked]
*/
internal class ModulesViewModel(modulesDTO: ModulesDTO) {
val checkBoxItems: PersistentMap<ModuleName, ModuleInfo> = persistentMapOf(*modulesDTO.modules
.map { (moduleName, parent) ->
Pair(moduleName, ModuleInfo(parent, pythons = persistentListOf(*fakePythons)))
val icons: PersistentMap<ToolIdDTO, IconKey> = persistentMapOf(*modulesDTO.modules.values.mapNotNull {
when (it) {
is CreateSdkDTO.ConfigurableModule -> it.createdByTool
is CreateSdkDTO.SameAs -> null
}
}.mapNotNull { toolId ->
val icon = getIcon(ToolId(toolId))?.let { IntelliJIconKey.fromPlatformIcon(it.first, it.second) } ?: return@mapNotNull null
Pair(toolId, icon)
}.toTypedArray())
val checkBoxItems: PersistentMap<ModuleName, CreateSdkDTO> = persistentMapOf(*modulesDTO.modules
.map { (moduleName, createSdkInfo) ->
Pair(moduleName, createSdkInfo)
}.toTypedArray())
val checked: SnapshotStateSet<ModuleName> = mutableStateSetOf()
private val children = mutableMapOf<ModuleName, MutableSet<ModuleName>>()
init {
for ((child, parent) in modulesDTO.modules) {
if (parent == null) continue
for ((child, createSdkDTO) in modulesDTO.modules) {
val parent = when (createSdkDTO) {
is CreateSdkDTO.ConfigurableModule -> continue
is CreateSdkDTO.SameAs -> createSdkDTO.parentModuleName
}
children.getOrPut(parent) { HashSet() }.add(child)
}
}
@@ -41,4 +57,3 @@ internal class ModulesViewModel(modulesDTO: ModulesDTO) {
}
}
internal data class ModuleInfo(val parent: String?, val pythons: ImmutableList<String>)

View File

@@ -23,7 +23,7 @@ internal class FrontendTopicListener : ProjectRemoteTopicListener<ModulesDTO> {
// Ask user to choose modules, then ask backend to configure it
askUser(project, event) { modulesChosenByUser ->
scope.launch {
SdkConfiguratorBackEndApi().configureSdkAutomatically(project.projectId(), modulesChosenByUser)
SdkConfiguratorBackEndApi().configureSdkForModules(project.projectId(), modulesChosenByUser)
}
}
}

View File

@@ -38,7 +38,10 @@ private class MyDialog(project: Project, private val viewModel: ModulesViewModel
override fun createCenterPanel(): JComponent {
enableNewSwingCompositing()
return compose(focusOnClickInside = true, content = {
ModuleList(viewModel.checkBoxItems, viewModel.checked, viewModel::clicked,
ModuleList(viewModel.checkBoxItems,
viewModel.icons,
viewModel.checked,
viewModel::clicked,
topLabel = message("python.sdk.configurator.frontend.choose.modules.text"),
projectStructureLabel = message("python.sdk.configurator.frontend.choose.modules.project.structure"),
environmentLabel = message("python.sdk.configurator.frontend.choose.modules.environment"))

View File

@@ -17,8 +17,12 @@ import org.jetbrains.annotations.CheckReturnValue
@ApiStatus.Internal
interface PyProjectSdkConfigurationExtension {
companion object {
@JvmStatic
val EP_NAME: ExtensionPointName<PyProjectSdkConfigurationExtension> = ExtensionPointName.create("Pythonid.projectSdkConfigurationExtension")
private val EP_NAME: ExtensionPointName<PyProjectSdkConfigurationExtension> = ExtensionPointName.create("Pythonid.projectSdkConfigurationExtension")
/**
* EPs associated by tool id
*/
fun createMap(): Map<ToolId, PyProjectSdkConfigurationExtension> = EP_NAME.extensionList.associateBy { it.toolId }
/**
* We return all configurators in a sorted order. The order is determined by extensions order, but existing environments have a

View File

@@ -10,4 +10,5 @@ import javax.swing.Icon
internal class UvToolIdMapper : ToolIdToIconMapper {
override val id: ToolId = UV_TOOL_ID
override val icon: Icon = PythonCommunityImplUVCommonIcons.UV
override val clazz: Class<*> = PythonCommunityImplUVCommonIcons::class.java
}